updating modules to look like production

pull/6/head
accius 3 days ago
parent e5bbeff069
commit 6adc7e9fdf

@ -8,14 +8,14 @@ import React, { useState, useEffect, useCallback, useMemo } from 'react';
import {
Header,
WorldMap,
SpaceWeatherPanel,
BandConditionsPanel,
DXClusterPanel,
POTAPanel,
ContestPanel,
LocationPanel,
SettingsPanel,
DXFilterManager
DXFilterManager,
SolarPanel,
PropagationPanel,
DXpeditionPanel
} from './components';
// Hooks
@ -62,11 +62,10 @@ const App = () => {
return config.defaultDX;
});
// Save DX location when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_dxLocation', JSON.stringify(dxLocation));
} catch (e) { console.error('Failed to save DX location:', e); }
} catch (e) {}
}, [dxLocation]);
// UI state
@ -74,88 +73,65 @@ const App = () => {
const [showDXFilters, setShowDXFilters] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
// Map layer visibility state with localStorage persistence
// Map layer visibility
const [mapLayers, setMapLayers] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_mapLayers');
const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: true };
const defaults = { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false };
return stored ? { ...defaults, ...JSON.parse(stored) } : defaults;
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: true }; }
} catch (e) { return { showDXPaths: true, showDXLabels: true, showPOTA: true, showSatellites: false }; }
});
// Save map layer preferences when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_mapLayers', JSON.stringify(mapLayers));
} catch (e) { console.error('Failed to save map layers:', e); }
} catch (e) {}
}, [mapLayers]);
// Hovered spot state for highlighting paths on map
const [hoveredSpot, setHoveredSpot] = useState(null);
// Toggle handlers for map layers
const toggleDXPaths = useCallback(() => setMapLayers(prev => ({ ...prev, showDXPaths: !prev.showDXPaths })), []);
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 })), []);
// 12/24 hour format preference with localStorage persistence
// 12/24 hour format
const [use12Hour, setUse12Hour] = useState(() => {
try {
const saved = localStorage.getItem('openhamclock_use12Hour');
return saved === 'true';
return localStorage.getItem('openhamclock_use12Hour') === 'true';
} catch (e) { return false; }
});
// Save 12/24 hour preference when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_use12Hour', use12Hour.toString());
} catch (e) { console.error('Failed to save time format:', e); }
} catch (e) {}
}, [use12Hour]);
// Toggle time format handler
const handleTimeFormatToggle = useCallback(() => {
setUse12Hour(prev => !prev);
}, []);
const handleTimeFormatToggle = useCallback(() => setUse12Hour(prev => !prev), []);
// Fullscreen toggle handler
// Fullscreen
const handleFullscreenToggle = useCallback(() => {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().then(() => {
setIsFullscreen(true);
}).catch(err => {
console.error('Fullscreen error:', err);
});
document.documentElement.requestFullscreen().then(() => setIsFullscreen(true)).catch(() => {});
} else {
document.exitFullscreen().then(() => {
setIsFullscreen(false);
}).catch(err => {
console.error('Exit fullscreen error:', err);
});
document.exitFullscreen().then(() => setIsFullscreen(false)).catch(() => {});
}
}, []);
// Listen for fullscreen changes
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
const handler = () => setIsFullscreen(!!document.fullscreenElement);
document.addEventListener('fullscreenchange', handler);
return () => document.removeEventListener('fullscreenchange', handler);
}, []);
// Apply theme on initial load
useEffect(() => {
applyTheme(config.theme || 'dark');
}, []);
// Check if this is first run
useEffect(() => {
const saved = localStorage.getItem('openhamclock_config');
if (!saved) {
setShowSettings(true);
}
if (!saved) setShowSettings(true);
}, []);
const handleSaveConfig = (newConfig) => {
@ -170,7 +146,7 @@ const App = () => {
const solarIndices = useSolarIndices();
const potaSpots = usePOTASpots();
// DX Cluster filters with localStorage persistence
// DX Filters
const [dxFilters, setDxFilters] = useState(() => {
try {
const stored = localStorage.getItem('openhamclock_dxFilters');
@ -178,7 +154,6 @@ const App = () => {
} catch (e) { return {}; }
});
// Save DX filters when changed
useEffect(() => {
try {
localStorage.setItem('openhamclock_dxFilters', JSON.stringify(dxFilters));
@ -200,7 +175,7 @@ const App = () => {
const deSunTimes = useMemo(() => calculateSunTimes(config.location.lat, config.location.lon, currentTime), [config.location, currentTime]);
const dxSunTimes = useMemo(() => calculateSunTimes(dxLocation.lat, dxLocation.lon, currentTime), [dxLocation, currentTime]);
// Time and uptime update
// Time update
useEffect(() => {
const timer = setInterval(() => {
setCurrentTime(new Date());
@ -223,9 +198,8 @@ const App = () => {
const utcDate = currentTime.toISOString().substr(0, 10);
const localDate = currentTime.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric' });
// Scale factor for modern layout
// Scale for small screens
const [scale, setScale] = useState(1);
useEffect(() => {
const calculateScale = () => {
const minWidth = 1200;
@ -239,7 +213,6 @@ const App = () => {
return () => window.removeEventListener('resize', calculateScale);
}, []);
// Modern Layout
return (
<div style={{
width: '100vw',
@ -256,7 +229,7 @@ const App = () => {
transform: `scale(${scale})`,
transformOrigin: 'center center',
display: 'grid',
gridTemplateColumns: '280px 1fr 280px',
gridTemplateColumns: '260px 1fr 300px',
gridTemplateRows: '50px 1fr',
gap: '8px',
padding: '8px',
@ -279,31 +252,51 @@ const App = () => {
isFullscreen={isFullscreen}
/>
{/* LEFT COLUMN */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
overflow: 'hidden'
}}>
<LocationPanel
config={config}
dxLocation={dxLocation}
deSunTimes={deSunTimes}
dxSunTimes={dxSunTimes}
currentTime={currentTime}
{/* LEFT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflowY: 'auto', overflowX: '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>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '13px' }}>
<div style={{ color: 'var(--accent-amber)', fontSize: '18px', fontWeight: '700' }}>{deGrid}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '12px', marginTop: '2px' }}>{config.location.lat.toFixed(2)}°, {config.location.lon.toFixed(2)}°</div>
<div style={{ marginTop: '6px', fontSize: '12px' }}>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-amber)' }}>{deSunTimes.sunrise}</span>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-purple)' }}>{deSunTimes.sunset}</span>
</div>
</div>
</div>
{/* DX Location */}
<div className="panel" style={{ padding: '12px', flex: '0 0 auto' }}>
<div style={{ fontSize: '13px', color: 'var(--accent-green)', fontWeight: '700', marginBottom: '8px' }}>🎯 DX - TARGET</div>
<div style={{ fontFamily: 'JetBrains Mono', fontSize: '13px' }}>
<div style={{ color: 'var(--accent-amber)', fontSize: '18px', fontWeight: '700' }}>{dxGrid}</div>
<div style={{ color: 'var(--text-secondary)', fontSize: '12px', marginTop: '2px' }}>{dxLocation.lat.toFixed(2)}°, {dxLocation.lon.toFixed(2)}°</div>
<div style={{ marginTop: '6px', fontSize: '12px' }}>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-amber)' }}>{dxSunTimes.sunrise}</span>
<span style={{ color: 'var(--text-secondary)' }}> </span>
<span style={{ color: 'var(--accent-purple)' }}>{dxSunTimes.sunset}</span>
</div>
</div>
</div>
{/* Solar Panel */}
<SolarPanel solarIndices={solarIndices} />
{/* VOACAP/Propagation Panel */}
<PropagationPanel
propagation={propagation.data}
loading={propagation.loading}
bandConditions={bandConditions}
/>
<SpaceWeatherPanel data={spaceWeather.data} loading={spaceWeather.loading} />
<BandConditionsPanel data={bandConditions.data} loading={bandConditions.loading} />
</div>
{/* CENTER - MAP */}
<div style={{
background: 'var(--bg-panel)',
border: '1px solid var(--border-color)',
borderRadius: '6px',
overflow: 'hidden'
}}>
<div style={{ position: 'relative', borderRadius: '6px', overflow: 'hidden' }}>
<WorldMap
deLocation={config.location}
dxLocation={dxLocation}
@ -321,25 +314,49 @@ const App = () => {
onToggleSatellites={toggleSatellites}
hoveredSpot={hoveredSpot}
/>
<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}
</div>
</div>
{/* RIGHT COLUMN */}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
overflow: 'hidden'
}}>
{/* RIGHT SIDEBAR */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'hidden' }}>
{/* DX Cluster */}
<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}
/>
{/* DXpeditions */}
<DXpeditionPanel data={dxpeditions.data} loading={dxpeditions.loading} />
{/* POTA */}
<POTAPanel
data={potaSpots.data}
loading={potaSpots.loading}
showOnMap={mapLayers.showPOTA}
onToggleMap={togglePOTA}
/>
<POTAPanel data={potaSpots.data} loading={potaSpots.loading} />
{/* Contests */}
<ContestPanel data={contests.data} loading={contests.loading} />
</div>
</div>

@ -1,6 +1,6 @@
/**
* DXClusterPanel Component
* Displays DX cluster spots with filtering controls
* Displays DX cluster spots with filtering controls and ON/OFF toggle
*/
import React from 'react';
import { getBandColor } from '../utils/callsign.js';
@ -10,9 +10,12 @@ export const DXClusterPanel = ({
loading,
totalSpots,
filters,
onFilterChange,
onOpenFilters,
onHoverSpot,
hoveredSpot
hoveredSpot,
showOnMap,
onToggleMap
}) => {
const getActiveFilterCount = () => {
let count = 0;
@ -32,45 +35,77 @@ export const DXClusterPanel = ({
return (
<div className="panel" style={{
padding: '12px',
padding: '10px',
display: 'flex',
flexDirection: 'column',
height: '100%',
overflow: 'hidden'
flex: '1 1 auto',
overflow: 'hidden',
minHeight: 0
}}>
{/* Header with filter button */}
{/* Header */}
<div style={{
fontSize: '12px',
color: 'var(--accent-green)',
fontWeight: '700',
marginBottom: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
alignItems: 'center'
}}>
<div className="panel-header" style={{ margin: 0 }}>
📻 DX CLUSTER
<span style={{
fontSize: '10px',
color: 'var(--text-muted)',
fontWeight: '400',
marginLeft: '8px'
}}>
{data.length}/{totalSpots || 0}
</span>
<span>🌐 DX CLUSTER <span style={{ color: 'var(--accent-green)', fontSize: '10px' }}> LIVE</span></span>
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<span style={{ fontSize: '8px', color: 'var(--text-muted)' }}>{data?.length || 0}/{totalSpots || 0}</span>
<button
onClick={onOpenFilters}
style={{
background: filterCount > 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
</button>
<button
onClick={onToggleMap}
style={{
background: showOnMap ? 'rgba(68, 136, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#4488ff' : '#666'}`,
color: showOnMap ? '#4488ff' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
🗺 {showOnMap ? 'ON' : 'OFF'}
</button>
</div>
<button
onClick={onOpenFilters}
</div>
{/* Quick search */}
<div style={{ display: 'flex', gap: '4px', marginBottom: '6px' }}>
<input
type="text"
placeholder="Quick search..."
value={filters?.callsign || ''}
onChange={(e) => onFilterChange?.({ ...filters, callsign: e.target.value || undefined })}
style={{
background: filterCount > 0 ? 'rgba(0, 221, 255, 0.15)' : 'var(--bg-tertiary)',
border: `1px solid ${filterCount > 0 ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
color: filterCount > 0 ? 'var(--accent-cyan)' : 'var(--text-secondary)',
padding: '4px 10px',
borderRadius: '4px',
flex: 1,
padding: '3px 6px',
background: 'var(--bg-secondary)',
border: '1px solid var(--border-color)',
borderRadius: '3px',
color: 'var(--text-primary)',
fontSize: '11px',
cursor: 'pointer',
fontFamily: 'JetBrains Mono, monospace'
fontFamily: 'JetBrains Mono'
}}
>
🔍 {filterCount > 0 ? `Filters (${filterCount})` : 'Filters'}
</button>
/>
</div>
{/* Spots list */}
@ -78,7 +113,7 @@ export const DXClusterPanel = ({
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : data.length === 0 ? (
) : !data || data.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '20px',
@ -94,9 +129,9 @@ export const DXClusterPanel = ({
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{data.slice(0, 15).map((spot, i) => {
{data.slice(0, 20).map((spot, i) => {
const freq = parseFloat(spot.freq);
const color = getBandColor(freq / 1000); // Convert kHz to MHz for color
const color = getBandColor(freq / 1000);
const isHovered = hoveredSpot?.call === spot.call &&
Math.abs(parseFloat(hoveredSpot?.freq) - freq) < 1;
@ -107,11 +142,11 @@ export const DXClusterPanel = ({
onMouseLeave={() => onHoverSpot?.(null)}
style={{
display: 'grid',
gridTemplateColumns: '70px 1fr auto',
gap: '8px',
padding: '6px 8px',
borderRadius: '4px',
marginBottom: '2px',
gridTemplateColumns: '55px 1fr auto',
gap: '6px',
padding: '4px 6px',
borderRadius: '3px',
marginBottom: '1px',
background: isHovered ? 'rgba(68, 136, 255, 0.2)' : (i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'),
cursor: 'pointer',
transition: 'background 0.15s'

@ -0,0 +1,85 @@
/**
* DXpeditionPanel Component
* Shows active and upcoming DXpeditions
*/
import React from 'react';
export const DXpeditionPanel = ({ data, loading }) => {
const getStatusStyle = (expedition) => {
if (expedition.isActive) {
return { bg: 'rgba(0, 255, 136, 0.15)', border: 'var(--accent-green)', color: 'var(--accent-green)' };
}
if (expedition.isUpcoming) {
return { bg: 'rgba(0, 170, 255, 0.15)', border: 'var(--accent-cyan)', color: 'var(--accent-cyan)' };
}
return { bg: 'var(--bg-tertiary)', border: 'var(--border-color)', color: 'var(--text-muted)' };
};
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span>🌍 DXPEDITIONS</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{loading && <div className="loading-spinner" />}
{data && (
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{data.active > 0 && <span style={{ color: 'var(--accent-green)' }}>{data.active} active</span>}
{data.active > 0 && data.upcoming > 0 && ' • '}
{data.upcoming > 0 && <span style={{ color: 'var(--accent-cyan)' }}>{data.upcoming} upcoming</span>}
</span>
)}
</div>
</div>
<div style={{ maxHeight: '200px', overflowY: 'auto' }}>
{data?.dxpeditions?.length > 0 ? (
data.dxpeditions.slice(0, 15).map((exp, idx) => {
const style = getStatusStyle(exp);
return (
<div key={idx} style={{
padding: '6px 8px',
marginBottom: '4px',
background: style.bg,
borderLeft: `3px solid ${style.border}`,
borderRadius: '4px',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace'
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span style={{ color: 'var(--accent-amber)', fontWeight: '700', fontSize: '13px' }}>{exp.callsign}</span>
<span style={{ color: style.color, fontSize: '10px' }}>
{exp.isActive ? '● NOW' : exp.isUpcoming ? 'UPCOMING' : 'PAST'}
</span>
</div>
<div style={{ color: 'var(--text-secondary)', marginTop: '2px' }}>
{exp.entity}
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '3px' }}>
<span style={{ color: 'var(--text-muted)', fontSize: '11px' }}>{exp.dates}</span>
<div style={{ display: 'flex', gap: '6px', fontSize: '10px' }}>
{exp.bands && <span style={{ color: 'var(--accent-purple)' }}>{exp.bands.split(' ').slice(0, 3).join(' ')}</span>}
{exp.modes && <span style={{ color: 'var(--accent-cyan)' }}>{exp.modes.split(' ').slice(0, 2).join(' ')}</span>}
</div>
</div>
</div>
);
})
) : (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
{loading ? 'Loading DXpeditions...' : 'No DXpedition data available'}
</div>
)}
</div>
{data && (
<div style={{ marginTop: '6px', textAlign: 'right', fontSize: '9px' }}>
<a href="https://www.ng3k.com/misc/adxo.html" target="_blank" rel="noopener noreferrer" style={{ color: 'var(--text-muted)', textDecoration: 'none' }}>
NG3K ADXO Calendar
</a>
</div>
)}
</div>
);
};
export default DXpeditionPanel;

@ -1,72 +1,64 @@
/**
* POTAPanel Component
* Displays Parks on the Air activations
* Displays Parks on the Air activations with ON/OFF toggle
*/
import React from 'react';
export const POTAPanel = ({ data, loading }) => {
export const POTAPanel = ({ data, loading, showOnMap, onToggleMap }) => {
return (
<div className="panel" style={{ padding: '12px' }}>
<div className="panel-header">🌲 POTA ACTIVATIONS</div>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }}>
<span>🏕 POTA ACTIVATORS</span>
<button
onClick={onToggleMap}
style={{
background: showOnMap ? 'rgba(170, 102, 255, 0.3)' : 'rgba(100, 100, 100, 0.3)',
border: `1px solid ${showOnMap ? '#aa66ff' : '#666'}`,
color: showOnMap ? '#aa66ff' : '#888',
padding: '2px 8px',
borderRadius: '4px',
fontSize: '10px',
fontFamily: 'JetBrains Mono',
cursor: 'pointer'
}}
>
🗺 {showOnMap ? 'ON' : 'OFF'}
</button>
</div>
{loading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: '20px' }}>
<div className="loading-spinner" />
</div>
) : data.length === 0 ? (
<div style={{
textAlign: 'center',
padding: '20px',
color: 'var(--text-muted)',
fontSize: '12px'
}}>
No active POTA spots
</div>
) : (
<div style={{
fontSize: '11px',
fontFamily: 'JetBrains Mono, monospace'
}}>
) : data && data.length > 0 ? (
<div style={{ fontSize: '11px', fontFamily: 'JetBrains Mono, monospace' }}>
{data.slice(0, 5).map((spot, i) => (
<div
key={`${spot.call}-${spot.ref}-${i}`}
style={{
padding: '8px',
borderRadius: '4px',
marginBottom: '4px',
background: i % 2 === 0 ? 'rgba(255,255,255,0.02)' : 'transparent'
<div
key={`${spot.call}-${i}`}
style={{
display: 'grid',
gridTemplateColumns: '70px 70px 1fr',
gap: '8px',
padding: '4px 0',
borderBottom: i < data.length - 1 ? '1px solid var(--border-color)' : 'none'
}}
>
<div style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: '4px'
}}>
<span style={{ color: 'var(--accent-green)', fontWeight: '600' }}>
{spot.call}
</span>
<span style={{ color: 'var(--accent-amber)' }}>
{spot.freq} {spot.mode}
</span>
</div>
<div style={{
display: 'flex',
justifyContent: 'space-between',
color: 'var(--text-muted)',
fontSize: '10px'
}}>
<span style={{
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '70%'
}}>
{spot.ref} - {spot.name}
</span>
<span>{spot.time}</span>
</div>
<span style={{ color: 'var(--accent-purple)', fontWeight: '600' }}>
{spot.call}
</span>
<span style={{ color: 'var(--text-muted)' }}>
{spot.ref}
</span>
<span style={{ color: 'var(--accent-cyan)', textAlign: 'right' }}>
{spot.freq}
</span>
</div>
))}
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
No active POTA spots
</div>
)}
</div>
);

@ -0,0 +1,312 @@
/**
* PropagationPanel Component (VOACAP)
* Toggleable between heatmap chart, bar chart, and band conditions view
*/
import React, { useState } from 'react';
export const PropagationPanel = ({ propagation, loading, bandConditions }) => {
// Load view mode preference from localStorage
const [viewMode, setViewMode] = useState(() => {
try {
const saved = localStorage.getItem('openhamclock_voacapViewMode');
if (saved === 'bars' || saved === 'bands') return saved;
return 'chart';
} catch (e) { return 'chart'; }
});
// Cycle through view modes
const cycleViewMode = () => {
const modes = ['chart', 'bars', 'bands'];
const currentIdx = modes.indexOf(viewMode);
const newMode = modes[(currentIdx + 1) % modes.length];
setViewMode(newMode);
try {
localStorage.setItem('openhamclock_voacapViewMode', newMode);
} catch (e) {}
};
const getBandStyle = (condition) => ({
GOOD: { bg: 'rgba(0,255,136,0.2)', color: '#00ff88', border: 'rgba(0,255,136,0.4)' },
FAIR: { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' },
POOR: { bg: 'rgba(255,68,102,0.2)', color: '#ff4466', border: 'rgba(255,68,102,0.4)' }
}[condition] || { bg: 'rgba(255,180,50,0.2)', color: '#ffb432', border: 'rgba(255,180,50,0.4)' });
if (loading || !propagation) {
return (
<div className="panel">
<div className="panel-header">📡 VOACAP</div>
<div style={{ padding: '20px', textAlign: 'center', color: 'var(--text-muted)' }}>
Loading predictions...
</div>
</div>
);
}
const { solarData, distance, currentBands, currentHour, hourlyPredictions, muf, luf, ionospheric, dataSource } = propagation;
const hasRealData = ionospheric?.method === 'direct' || ionospheric?.method === 'interpolated';
// Heat map colors (VOACAP style - red=good, green=poor)
const getHeatColor = (rel) => {
if (rel >= 80) return '#ff0000';
if (rel >= 60) return '#ff6600';
if (rel >= 40) return '#ffcc00';
if (rel >= 20) return '#88cc00';
if (rel >= 10) return '#00aa00';
return '#004400';
};
const getReliabilityColor = (rel) => {
if (rel >= 70) return '#00ff88';
if (rel >= 50) return '#88ff00';
if (rel >= 30) return '#ffcc00';
if (rel >= 15) return '#ff8800';
return '#ff4444';
};
const getStatusColor = (status) => {
switch (status) {
case 'EXCELLENT': return '#00ff88';
case 'GOOD': return '#88ff00';
case 'FAIR': return '#ffcc00';
case 'POOR': return '#ff8800';
case 'CLOSED': return '#ff4444';
default: return 'var(--text-muted)';
}
};
const bands = ['80m', '40m', '30m', '20m', '17m', '15m', '12m', '11m', '10m'];
const viewModeLabels = { chart: '▤ chart', bars: '▦ bars', bands: '◫ bands' };
return (
<div className="panel" style={{ cursor: 'pointer' }} onClick={cycleViewMode}>
<div className="panel-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<span>
{viewMode === 'bands' ? '📊 BAND CONDITIONS' : '📡 VOACAP'}
{hasRealData && viewMode !== 'bands' && <span style={{ color: '#00ff88', fontSize: '10px', marginLeft: '4px' }}></span>}
</span>
<span style={{ fontSize: '10px', color: 'var(--text-muted)' }}>
{viewModeLabels[viewMode]} click to toggle
</span>
</div>
{viewMode === 'bands' ? (
/* Band Conditions Grid View */
<div style={{ padding: '4px' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '4px' }}>
{(bandConditions?.data || []).slice(0, 13).map((band, idx) => {
const style = getBandStyle(band.condition);
return (
<div key={idx} style={{
background: style.bg,
border: `1px solid ${style.border}`,
borderRadius: '4px',
padding: '6px 2px',
textAlign: 'center'
}}>
<div style={{ fontFamily: 'Orbitron, monospace', fontSize: '13px', fontWeight: '700', color: style.color }}>
{band.band}
</div>
<div style={{ fontSize: '9px', fontWeight: '600', color: style.color, marginTop: '2px', opacity: 0.8 }}>
{band.condition}
</div>
</div>
);
})}
</div>
<div style={{ marginTop: '6px', fontSize: '10px', color: 'var(--text-muted)', textAlign: 'center' }}>
SFI {solarData?.sfi} K {solarData?.kIndex} General conditions for all paths
</div>
</div>
) : (
<>
{/* MUF/LUF and Data Source Info */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
padding: '4px 8px',
background: hasRealData ? 'rgba(0, 255, 136, 0.1)' : 'var(--bg-tertiary)',
borderRadius: '4px',
marginBottom: '4px',
fontSize: '11px'
}}>
<div style={{ display: 'flex', gap: '12px' }}>
<span>
<span style={{ color: 'var(--text-muted)' }}>MUF </span>
<span style={{ color: '#ff8800', fontWeight: '600' }}>{muf || '?'}</span>
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
</span>
<span>
<span style={{ color: 'var(--text-muted)' }}>LUF </span>
<span style={{ color: '#00aaff', fontWeight: '600' }}>{luf || '?'}</span>
<span style={{ color: 'var(--text-muted)' }}> MHz</span>
</span>
</div>
<span style={{ color: hasRealData ? '#00ff88' : 'var(--text-muted)', fontSize: '10px' }}>
{hasRealData
? `📡 ${ionospheric?.source || 'ionosonde'}${ionospheric?.distance ? ` (${ionospheric.distance}km)` : ''}`
: '⚡ estimated'
}
</span>
{dataSource && dataSource.includes('ITU') && (
<span style={{
color: '#ff6b35',
fontSize: '9px',
marginLeft: '8px',
padding: '1px 4px',
background: 'rgba(255,107,53,0.15)',
borderRadius: '3px'
}}>
🔬 ITU-R P.533
</span>
)}
</div>
{viewMode === 'chart' ? (
/* VOACAP Heat Map Chart View */
<div style={{ padding: '4px' }}>
<div style={{
display: 'grid',
gridTemplateColumns: '28px repeat(24, 1fr)',
gridTemplateRows: `repeat(${bands.length}, 12px)`,
gap: '1px',
fontSize: '12px',
fontFamily: 'JetBrains Mono, monospace'
}}>
{bands.map((band) => (
<React.Fragment key={band}>
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
paddingRight: '4px',
color: 'var(--text-muted)',
fontSize: '12px'
}}>
{band.replace('m', '')}
</div>
{Array.from({ length: 24 }, (_, hour) => {
let rel = 0;
if (hour === currentHour && currentBands?.length > 0) {
const currentBandData = currentBands.find(b => b.band === band);
if (currentBandData) {
rel = currentBandData.reliability || 0;
}
} else {
const bandData = hourlyPredictions?.[band];
const hourData = bandData?.find(h => h.hour === hour);
rel = hourData?.reliability || 0;
}
return (
<div
key={hour}
style={{
background: getHeatColor(rel),
borderRadius: '1px',
border: hour === currentHour ? '1px solid white' : 'none'
}}
title={`${band} @ ${hour}:00 UTC: ${rel}%`}
/>
);
})}
</React.Fragment>
))}
</div>
{/* Hour labels */}
<div style={{
display: 'grid',
gridTemplateColumns: '28px repeat(24, 1fr)',
marginTop: '2px',
fontSize: '9px',
color: 'var(--text-muted)'
}}>
<div>UTC</div>
{[0, '', '', 3, '', '', 6, '', '', 9, '', '', 12, '', '', 15, '', '', 18, '', '', 21, '', ''].map((h, i) => (
<div key={i} style={{ textAlign: 'center' }}>{h}</div>
))}
</div>
{/* Legend */}
<div style={{
marginTop: '6px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontSize: '11px'
}}>
<div style={{ display: 'flex', gap: '2px', alignItems: 'center' }}>
<span style={{ color: 'var(--text-muted)' }}>REL:</span>
{['#004400', '#00aa00', '#88cc00', '#ffcc00', '#ff6600', '#ff0000'].map((c, i) => (
<div key={i} style={{ width: '8px', height: '8px', background: c, borderRadius: '1px' }} />
))}
</div>
<div style={{ color: 'var(--text-muted)' }}>
{Math.round(distance || 0)}km {ionospheric?.foF2 ? `foF2=${ionospheric.foF2}` : `SSN=${solarData?.ssn}`}
</div>
</div>
</div>
) : (
/* Bar Chart View */
<div style={{ fontSize: '13px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-around',
padding: '4px',
marginBottom: '4px',
background: 'var(--bg-tertiary)',
borderRadius: '4px',
fontSize: '11px'
}}>
<span><span style={{ color: 'var(--text-muted)' }}>SFI </span><span style={{ color: 'var(--accent-amber)' }}>{solarData?.sfi}</span></span>
{ionospheric?.foF2 ? (
<span><span style={{ color: 'var(--text-muted)' }}>foF2 </span><span style={{ color: '#00ff88' }}>{ionospheric.foF2}</span></span>
) : (
<span><span style={{ color: 'var(--text-muted)' }}>SSN </span><span style={{ color: 'var(--accent-cyan)' }}>{solarData?.ssn}</span></span>
)}
<span><span style={{ color: 'var(--text-muted)' }}>K </span><span style={{ color: solarData?.kIndex >= 4 ? '#ff4444' : '#00ff88' }}>{solarData?.kIndex}</span></span>
</div>
{(currentBands || []).slice(0, 11).map((band) => (
<div key={band.band} style={{
display: 'grid',
gridTemplateColumns: '32px 1fr 40px',
gap: '4px',
padding: '2px 0',
alignItems: 'center'
}}>
<span style={{
fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px',
color: band.reliability >= 50 ? 'var(--accent-green)' : 'var(--text-muted)'
}}>
{band.band}
</span>
<div style={{ position: 'relative', height: '10px', background: 'var(--bg-tertiary)', borderRadius: '2px' }}>
<div style={{
position: 'absolute',
top: 0,
left: 0,
height: '100%',
width: `${band.reliability}%`,
background: getReliabilityColor(band.reliability),
borderRadius: '2px'
}} />
</div>
<span style={{
textAlign: 'right',
fontSize: '12px',
color: getStatusColor(band.status)
}}>
{band.reliability}%
</span>
</div>
))}
</div>
)}
</>
)}
</div>
);
};
export default PropagationPanel;

@ -0,0 +1,215 @@
/**
* SolarPanel Component
* Toggleable between live sun image from NASA SDO and solar indices display
*/
import React, { useState } from 'react';
export const SolarPanel = ({ solarIndices }) => {
const [showIndices, setShowIndices] = useState(() => {
try {
const saved = localStorage.getItem('openhamclock_solarPanelMode');
return saved === 'indices';
} catch (e) { return false; }
});
const [imageType, setImageType] = useState('0193'); // AIA 193 (corona)
const toggleMode = () => {
const newMode = !showIndices;
setShowIndices(newMode);
try {
localStorage.setItem('openhamclock_solarPanelMode', newMode ? 'indices' : 'image');
} catch (e) {}
};
// SDO/AIA image types
const imageTypes = {
'0193': { name: 'AIA 193Å', desc: 'Corona' },
'0304': { name: 'AIA 304Å', desc: 'Chromosphere' },
'0171': { name: 'AIA 171Å', desc: 'Quiet Corona' },
'0094': { name: 'AIA 94Å', desc: 'Flaring' },
'HMIIC': { name: 'HMI Int', desc: 'Visible' }
};
// SDO images update every ~15 minutes
const timestamp = Math.floor(Date.now() / 900000) * 900000;
const imageUrl = `https://sdo.gsfc.nasa.gov/assets/img/latest/latest_256_${imageType}.jpg?t=${timestamp}`;
const getKpColor = (value) => {
if (value >= 7) return '#ff0000';
if (value >= 5) return '#ff6600';
if (value >= 4) return '#ffcc00';
if (value >= 3) return '#88cc00';
return '#00ff88';
};
return (
<div className="panel" style={{ padding: '8px' }}>
{/* Header with toggle */}
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '6px'
}}>
<span style={{ fontSize: '12px', color: 'var(--accent-amber)', fontWeight: '700' }}>
{showIndices ? 'SOLAR INDICES' : 'SOLAR'}
</span>
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }}>
{!showIndices && (
<select
value={imageType}
onChange={(e) => setImageType(e.target.value)}
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '10px',
padding: '2px 4px',
borderRadius: '3px',
cursor: 'pointer'
}}
>
{Object.entries(imageTypes).map(([key, val]) => (
<option key={key} value={key}>{val.desc}</option>
))}
</select>
)}
<button
onClick={toggleMode}
style={{
background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)',
color: 'var(--text-secondary)',
fontSize: '10px',
padding: '2px 6px',
borderRadius: '3px',
cursor: 'pointer'
}}
title={showIndices ? 'Show solar image' : 'Show solar indices'}
>
{showIndices ? '🖼️' : '📊'}
</button>
</div>
</div>
{showIndices ? (
/* Solar Indices View */
<div>
{solarIndices?.data ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
{/* SFI Row */}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ minWidth: '60px' }}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SFI</div>
<div style={{ fontSize: '22px', fontWeight: '700', color: '#ff8800', fontFamily: 'Orbitron, monospace' }}>
{solarIndices.data.sfi?.current || '--'}
</div>
</div>
<div style={{ flex: 1 }}>
{solarIndices.data.sfi?.history?.length > 0 && (
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
{(() => {
const data = solarIndices.data.sfi.history.slice(-20);
const values = data.map(d => d.value);
const max = Math.max(...values, 1);
const min = Math.min(...values);
const range = max - min || 1;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * 100;
const y = 30 - ((d.value - min) / range) * 25;
return `${x},${y}`;
}).join(' ');
return <polyline points={points} fill="none" stroke="#ff8800" strokeWidth="1.5" />;
})()}
</svg>
)}
</div>
</div>
{/* K-Index Row */}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ minWidth: '60px' }}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>K-Index</div>
<div style={{ fontSize: '22px', fontWeight: '700', color: getKpColor(solarIndices.data.kIndex?.current), fontFamily: 'Orbitron, monospace' }}>
{solarIndices.data.kIndex?.current ?? '--'}
</div>
</div>
<div style={{ flex: 1 }}>
{solarIndices.data.kIndex?.forecast?.length > 0 && (
<div style={{ display: 'flex', gap: '2px', alignItems: 'flex-end', height: '30px' }}>
{solarIndices.data.kIndex.forecast.slice(0, 8).map((kp, i) => (
<div key={i} style={{
flex: 1,
height: `${Math.max(10, (kp / 9) * 100)}%`,
background: getKpColor(kp),
borderRadius: '2px',
opacity: 0.8
}} title={`Kp ${kp}`} />
))}
</div>
)}
</div>
</div>
{/* SSN Row */}
<div style={{ background: 'var(--bg-tertiary)', borderRadius: '6px', padding: '8px', display: 'flex', alignItems: 'center', gap: '12px' }}>
<div style={{ minWidth: '60px' }}>
<div style={{ fontSize: '10px', color: 'var(--text-muted)' }}>SSN</div>
<div style={{ fontSize: '22px', fontWeight: '700', color: '#aa88ff', fontFamily: 'Orbitron, monospace' }}>
{solarIndices.data.ssn?.current || '--'}
</div>
</div>
<div style={{ flex: 1 }}>
{solarIndices.data.ssn?.history?.length > 0 && (
<svg width="100%" height="30" viewBox="0 0 100 30" preserveAspectRatio="none">
{(() => {
const data = solarIndices.data.ssn.history.slice(-20);
const values = data.map(d => d.value);
const max = Math.max(...values, 1);
const min = Math.min(...values, 0);
const range = max - min || 1;
const points = data.map((d, i) => {
const x = (i / (data.length - 1)) * 100;
const y = 30 - ((d.value - min) / range) * 25;
return `${x},${y}`;
}).join(' ');
return <polyline points={points} fill="none" stroke="#aa88ff" strokeWidth="1.5" />;
})()}
</svg>
)}
</div>
</div>
</div>
) : (
<div style={{ textAlign: 'center', color: 'var(--text-muted)', padding: '20px' }}>
Loading solar data...
</div>
)}
</div>
) : (
/* Solar Image View */
<div style={{ textAlign: 'center' }}>
<img
src={imageUrl}
alt="SDO Solar Image"
style={{
width: '100%',
maxWidth: '200px',
borderRadius: '50%',
border: '2px solid var(--border-color)'
}}
onError={(e) => {
e.target.style.display = 'none';
}}
/>
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '4px' }}>
SDO/AIA Live from NASA
</div>
</div>
)}
</div>
);
};
export default SolarPanel;

@ -13,3 +13,6 @@ export { ContestPanel } from './ContestPanel.jsx';
export { LocationPanel } from './LocationPanel.jsx';
export { SettingsPanel } from './SettingsPanel.jsx';
export { DXFilterManager } from './DXFilterManager.jsx';
export { SolarPanel } from './SolarPanel.jsx';
export { PropagationPanel } from './PropagationPanel.jsx';
export { DXpeditionPanel } from './DXpeditionPanel.jsx';

Loading…
Cancel
Save

Powered by TurnKey Linux.