init commit

pull/1/head
accius 6 days ago
parent 33bc29b435
commit 66f3cf0a57

@ -1,6 +1,6 @@
{
"name": "openhamclock",
"version": "3.0.0",
"version": "3.1.0",
"description": "Open-source amateur radio dashboard with real-time space weather, band conditions, DX cluster, and interactive world map",
"main": "server.js",
"scripts": {

@ -207,11 +207,11 @@
const { useState, useEffect, useCallback, useMemo, useRef } = React;
// ============================================
// CONFIGURATION
// CONFIGURATION WITH LOCALSTORAGE
// ============================================
const CONFIG = {
callsign: 'K0CJH',
location: { lat: 39.7392, lon: -104.9903 }, // Denver, CO
const DEFAULT_CONFIG = {
callsign: 'N0CALL',
location: { lat: 40.0150, lon: -105.2705 }, // Boulder, CO (default)
defaultDX: { lat: 35.6762, lon: 139.6503 }, // Tokyo
refreshIntervals: {
spaceWeather: 300000,
@ -222,6 +222,29 @@
}
};
// Load config from localStorage or use defaults
const loadConfig = () => {
try {
const saved = localStorage.getItem('openhamclock_config');
if (saved) {
const parsed = JSON.parse(saved);
return { ...DEFAULT_CONFIG, ...parsed };
}
} catch (e) {
console.error('Error loading config:', e);
}
return DEFAULT_CONFIG;
};
// Save config to localStorage
const saveConfig = (config) => {
try {
localStorage.setItem('openhamclock_config', JSON.stringify(config));
} catch (e) {
console.error('Error saving config:', e);
}
};
// ============================================
// MAP TILE PROVIDERS
// ============================================
@ -457,19 +480,64 @@
useEffect(() => {
const fetchDX = async () => {
try {
// Fallback to sample data since DXWatch may have CORS issues
setData([
{ freq: '14.074', call: 'JA1ABC', comment: 'FT8 -12dB', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '21.074', call: 'VK2DEF', comment: 'FT8 -08dB', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '7.040', call: 'DL1XYZ', comment: 'CW 599', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '14.200', call: 'ZL3QRS', comment: 'SSB 59', time: new Date().toISOString().substr(11,5)+'z' },
{ freq: '28.074', call: 'LU5TUV', comment: 'FT8 -15dB', time: new Date().toISOString().substr(11,5)+'z' }
]);
} catch (err) { console.error('DX error:', err); }
// Try our proxy endpoint first (works when running via server.js)
let response = await fetch('/api/dxcluster/spots').catch(() => null);
if (response && response.ok) {
const spots = await response.json();
if (spots && spots.length > 0) {
setData(spots.slice(0, 15).map(s => ({
freq: s.frequency ? (parseFloat(s.frequency) / 1000).toFixed(3) : s.freq,
call: s.dx_call || s.call,
comment: s.comment || s.info || '',
time: s.time ? s.time.substr(11, 5) + 'z' : new Date().toISOString().substr(11, 5) + 'z',
spotter: s.spotter || ''
})));
setLoading(false);
return;
}
}
// Fallback: Try HamAlert/DXWatch direct (may have CORS issues)
response = await fetch('https://www.dxwatch.com/api/spots.json?limit=15', {
mode: 'cors',
headers: { 'Accept': 'application/json' }
}).catch(() => null);
if (response && response.ok) {
const spots = await response.json();
setData(spots.slice(0, 15).map(s => ({
freq: (parseFloat(s.frequency) / 1000).toFixed(3),
call: s.dx_call,
comment: s.comment || s.info || '',
time: s.time ? s.time.substr(11, 5) + 'z' : '',
spotter: s.spotter
})));
} else {
// Final fallback: PSKReporter-style data simulation based on real bands
// This shows the structure but indicates no live connection
setData([{
freq: '---',
call: 'NO DATA',
comment: 'Connect via server.js for live spots',
time: '--:--z',
spotter: ''
}]);
}
} catch (err) {
console.error('DX error:', err);
setData([{
freq: '---',
call: 'ERROR',
comment: 'Could not fetch DX spots',
time: '--:--z',
spotter: ''
}]);
}
finally { setLoading(false); }
};
fetchDX();
const interval = setInterval(fetchDX, CONFIG.refreshIntervals.dxCluster);
const interval = setInterval(fetchDX, 30000); // 30 second refresh
return () => clearInterval(interval);
}, []);
@ -661,7 +729,7 @@
// ============================================
// UI COMPONENTS
// ============================================
const Header = ({ callsign, uptime, version }) => (
const Header = ({ callsign, uptime, version, onSettingsClick }) => (
<header style={{
background: 'linear-gradient(180deg, var(--bg-secondary) 0%, var(--bg-primary) 100%)',
borderBottom: '1px solid var(--border-color)',
@ -675,16 +743,34 @@
fontFamily: 'Orbitron, monospace', fontSize: '28px', fontWeight: '700',
color: 'var(--accent-amber)', textShadow: '0 0 10px var(--accent-amber-dim)', letterSpacing: '2px'
}}>OpenHamClock</div>
<div style={{
<div
onClick={onSettingsClick}
style={{
background: 'var(--bg-tertiary)', padding: '6px 16px', borderRadius: '4px',
border: '1px solid var(--border-color)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '16px', fontWeight: '600', color: 'var(--accent-green)',
textShadow: '0 0 8px var(--accent-green-dim)'
}}>{callsign}</div>
textShadow: '0 0 8px var(--accent-green-dim)', cursor: 'pointer',
transition: 'all 0.2s'
}}
title="Click to change settings"
>{callsign}</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '24px', fontFamily: 'JetBrains Mono, monospace', fontSize: '12px', color: 'var(--text-secondary)' }}>
<span>UPTIME: {uptime}</span>
<span style={{ color: 'var(--accent-cyan)' }}>v{version}</span>
<button
onClick={onSettingsClick}
style={{
background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)',
borderRadius: '6px', padding: '8px 12px', color: 'var(--text-secondary)',
cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
fontFamily: 'JetBrains Mono, monospace', fontSize: '11px',
transition: 'all 0.2s'
}}
title="Settings"
>
⚙ Settings
</button>
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: 'var(--accent-green)', boxShadow: '0 0 8px var(--accent-green)', animation: 'pulse 2s infinite' }} />
</div>
</header>
@ -847,24 +933,288 @@
</div>
);
// ============================================
// SETTINGS PANEL COMPONENT
// ============================================
const SettingsPanel = ({ isOpen, onClose, config, onSave }) => {
const [callsign, setCallsign] = useState(config.callsign);
const [lat, setLat] = useState(config.location.lat.toString());
const [lon, setLon] = useState(config.location.lon.toString());
const [gridSquare, setGridSquare] = useState('');
const [useGeolocation, setUseGeolocation] = useState(false);
// Calculate grid square from lat/lon
useEffect(() => {
const latNum = parseFloat(lat);
const lonNum = parseFloat(lon);
if (!isNaN(latNum) && !isNaN(lonNum)) {
setGridSquare(calculateGridSquare(latNum, lonNum));
}
}, [lat, lon]);
const handleGeolocation = () => {
if (navigator.geolocation) {
setUseGeolocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLat(position.coords.latitude.toFixed(6));
setLon(position.coords.longitude.toFixed(6));
setUseGeolocation(false);
},
(error) => {
alert('Could not get location: ' + error.message);
setUseGeolocation(false);
}
);
} else {
alert('Geolocation is not supported by this browser');
}
};
const handleSave = () => {
const latNum = parseFloat(lat);
const lonNum = parseFloat(lon);
if (!callsign.trim()) {
alert('Please enter a callsign');
return;
}
if (isNaN(latNum) || latNum < -90 || latNum > 90) {
alert('Please enter a valid latitude (-90 to 90)');
return;
}
if (isNaN(lonNum) || lonNum < -180 || lonNum > 180) {
alert('Please enter a valid longitude (-180 to 180)');
return;
}
const newConfig = {
...config,
callsign: callsign.toUpperCase().trim(),
location: { lat: latNum, lon: lonNum }
};
onSave(newConfig);
onClose();
};
const handleGridSquareChange = (gs) => {
setGridSquare(gs.toUpperCase());
// Convert grid square to lat/lon if valid (4 or 6 char)
if (gs.length >= 4) {
try {
const gsUpper = gs.toUpperCase();
const lonField = gsUpper.charCodeAt(0) - 65;
const latField = gsUpper.charCodeAt(1) - 65;
const lonSquare = parseInt(gsUpper[2]);
const latSquare = parseInt(gsUpper[3]);
let lonVal = (lonField * 20) + (lonSquare * 2) - 180 + 1;
let latVal = (latField * 10) + latSquare - 90 + 0.5;
if (gs.length >= 6) {
const lonSub = gsUpper.charCodeAt(4) - 65;
const latSub = gsUpper.charCodeAt(5) - 65;
lonVal = (lonField * 20) + (lonSquare * 2) + (lonSub / 12) - 180 + (1/24);
latVal = (latField * 10) + latSquare + (latSub / 24) - 90 + (1/48);
}
if (!isNaN(lonVal) && !isNaN(latVal)) {
setLat(latVal.toFixed(6));
setLon(lonVal.toFixed(6));
}
} catch (e) {
// Invalid grid square, ignore
}
}
};
if (!isOpen) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
background: 'rgba(0,0,0,0.8)', display: 'flex', alignItems: 'center', justifyContent: 'center',
zIndex: 10000, backdropFilter: 'blur(5px)'
}} onClick={onClose}>
<div style={{
background: 'var(--bg-secondary)', border: '1px solid var(--border-color)',
borderRadius: '12px', padding: '30px', width: '90%', maxWidth: '450px',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)'
}} onClick={e => e.stopPropagation()}>
<h2 style={{
fontFamily: 'Orbitron, monospace', fontSize: '24px', color: 'var(--accent-amber)',
marginBottom: '24px', textAlign: 'center'
}}>
⚙ Station Settings
</h2>
{/* Callsign */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Your Callsign
</label>
<input
type="text"
value={callsign}
onChange={(e) => setCallsign(e.target.value.toUpperCase())}
placeholder="W1ABC"
style={{
width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', borderRadius: '6px',
color: 'var(--accent-green)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '18px', outline: 'none', textTransform: 'uppercase'
}}
/>
</div>
{/* Grid Square */}
<div style={{ marginBottom: '20px' }}>
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Grid Square (or enter lat/lon below)
</label>
<input
type="text"
value={gridSquare}
onChange={(e) => handleGridSquareChange(e.target.value)}
placeholder="FN31pr"
maxLength={6}
style={{
width: '100%', padding: '12px 16px', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', borderRadius: '6px',
color: 'var(--accent-cyan)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '18px', outline: 'none', textTransform: 'uppercase'
}}
/>
</div>
{/* Lat/Lon */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px', marginBottom: '20px' }}>
<div>
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Latitude
</label>
<input
type="text"
value={lat}
onChange={(e) => setLat(e.target.value)}
placeholder="40.0150"
style={{
width: '100%', padding: '10px 12px', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', borderRadius: '6px',
color: 'var(--text-primary)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '14px', outline: 'none'
}}
/>
</div>
<div>
<label style={{ display: 'block', color: 'var(--text-secondary)', fontSize: '12px', marginBottom: '6px', textTransform: 'uppercase', letterSpacing: '1px' }}>
Longitude
</label>
<input
type="text"
value={lon}
onChange={(e) => setLon(e.target.value)}
placeholder="-105.2705"
style={{
width: '100%', padding: '10px 12px', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', borderRadius: '6px',
color: 'var(--text-primary)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '14px', outline: 'none'
}}
/>
</div>
</div>
{/* Get Location Button */}
<button
onClick={handleGeolocation}
disabled={useGeolocation}
style={{
width: '100%', padding: '12px', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', borderRadius: '6px',
color: 'var(--text-secondary)', fontFamily: 'JetBrains Mono, monospace',
fontSize: '12px', cursor: 'pointer', marginBottom: '24px',
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '8px'
}}
>
{useGeolocation ? (
<><div className="loading-spinner" /> Getting location...</>
) : (
<>📍 Use My Current Location</>
)}
</button>
{/* Buttons */}
<div style={{ display: 'flex', gap: '12px' }}>
<button
onClick={onClose}
style={{
flex: 1, padding: '14px', background: 'var(--bg-tertiary)',
border: '1px solid var(--border-color)', borderRadius: '6px',
color: 'var(--text-secondary)', fontFamily: 'Space Grotesk, sans-serif',
fontSize: '14px', fontWeight: '600', cursor: 'pointer'
}}
>
Cancel
</button>
<button
onClick={handleSave}
style={{
flex: 1, padding: '14px', background: 'var(--accent-amber)',
border: 'none', borderRadius: '6px',
color: '#000', fontFamily: 'Space Grotesk, sans-serif',
fontSize: '14px', fontWeight: '700', cursor: 'pointer'
}}
>
Save Settings
</button>
</div>
<p style={{
marginTop: '20px', fontSize: '11px', color: 'var(--text-muted)',
textAlign: 'center', fontFamily: 'JetBrains Mono, monospace'
}}>
Settings are saved in your browser
</p>
</div>
</div>
);
};
// ============================================
// MAIN APP
// ============================================
const App = () => {
const [config, setConfig] = useState(loadConfig);
const [currentTime, setCurrentTime] = useState(new Date());
const [startTime] = useState(Date.now());
const [uptime, setUptime] = useState('0d 0h 0m');
const [deLocation] = useState(CONFIG.location);
const [dxLocation, setDxLocation] = useState(CONFIG.defaultDX);
const [dxLocation, setDxLocation] = useState(config.defaultDX);
const [showSettings, setShowSettings] = useState(false);
// Check if this is first run (no config saved)
useEffect(() => {
const saved = localStorage.getItem('openhamclock_config');
if (!saved) {
// First time user - show settings
setShowSettings(true);
}
}, []);
const handleSaveConfig = (newConfig) => {
setConfig(newConfig);
saveConfig(newConfig);
};
const spaceWeather = useSpaceWeather();
const bandConditions = useBandConditions();
const potaSpots = usePOTASpots();
const dxCluster = useDXCluster();
const deGrid = useMemo(() => calculateGridSquare(deLocation.lat, deLocation.lon), [deLocation]);
const deGrid = useMemo(() => calculateGridSquare(config.location.lat, config.location.lon), [config.location]);
const dxGrid = useMemo(() => calculateGridSquare(dxLocation.lat, dxLocation.lon), [dxLocation]);
const deSunTimes = useMemo(() => calculateSunTimes(deLocation.lat, deLocation.lon, currentTime), [deLocation, currentTime]);
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]);
useEffect(() => {
@ -890,23 +1240,23 @@
return (
<div style={{ minHeight: '100vh', background: 'var(--bg-primary)' }}>
<Header callsign={CONFIG.callsign} uptime={uptime} version="3.0.0" />
<Header callsign={config.callsign} uptime={uptime} version="3.1.0" onSettingsClick={() => setShowSettings(true)} />
<main style={{ padding: '20px', display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gridTemplateRows: 'auto 1fr auto', gap: '16px', maxWidth: '1800px', margin: '0 auto', minHeight: 'calc(100vh - 120px)' }}>
{/* Row 1 */}
<ClockPanel label="UTC Time" time={utcTime} date={utcDate} isUtc={true} />
<ClockPanel label="Local Time" time={localTime} date={localDate} isUtc={false} />
<LocationPanel type="DE" location={deLocation} gridSquare={deGrid} sunTimes={deSunTimes} otherLocation={dxLocation} />
<LocationPanel type="DE" location={config.location} gridSquare={deGrid} sunTimes={deSunTimes} otherLocation={dxLocation} />
{/* Row 2: Map */}
<div style={{ gridColumn: 'span 2', minHeight: '350px' }}>
<WorldMap deLocation={deLocation} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} />
<WorldMap deLocation={config.location} dxLocation={dxLocation} onDXChange={handleDXChange} potaSpots={potaSpots.data} />
<div style={{ fontSize: '10px', color: 'var(--text-muted)', marginTop: '8px', fontFamily: 'JetBrains Mono', textAlign: 'center' }}>
Click anywhere on map to set DX location • Use buttons to change map style
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<LocationPanel type="DX" location={dxLocation} gridSquare={dxGrid} sunTimes={dxSunTimes} otherLocation={deLocation} />
<LocationPanel type="DX" location={dxLocation} gridSquare={dxGrid} sunTimes={dxSunTimes} otherLocation={config.location} />
<SpaceWeatherPanel data={spaceWeather.data} loading={spaceWeather.loading} />
</div>
@ -917,8 +1267,16 @@
</main>
<footer style={{ textAlign: 'center', padding: '20px', color: 'var(--text-muted)', fontSize: '11px', fontFamily: 'JetBrains Mono, monospace' }}>
OpenHamClock v3.0.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {CONFIG.callsign}
OpenHamClock v3.1.0 | In memory of Elwood Downey WB0OEW | Map tiles © OpenStreetMap, ESRI, CARTO | 73 de {config.callsign}
</footer>
{/* Settings Panel */}
<SettingsPanel
isOpen={showSettings}
onClose={() => setShowSettings(false)}
config={config}
onSave={handleSaveConfig}
/>
</div>
);
};

@ -115,24 +115,95 @@ app.get('/api/hamqsl/conditions', async (req, res) => {
}
});
// DX Cluster proxy (for future WebSocket implementation)
// DX Cluster proxy - fetches from multiple sources
app.get('/api/dxcluster/spots', async (req, res) => {
try {
// Try DXWatch first
const response = await fetch('https://www.dxwatch.com/api/spots.json?limit=20', {
headers: { 'User-Agent': 'OpenHamClock/3.0' }
const response = await fetch('https://dxwatch.com/dxsd1/s.php?s=0&r=50&cdx=', {
headers: {
'User-Agent': 'OpenHamClock/3.0',
'Accept': 'application/json'
},
timeout: 5000
});
if (response.ok) {
const text = await response.text();
try {
// DXWatch returns JSON array
const data = JSON.parse(text);
const spots = data.map(spot => ({
freq: spot.fr ? (parseFloat(spot.fr) / 1000).toFixed(3) : spot.frequency,
call: spot.dx || spot.dx_call,
comment: spot.cm || spot.comment || '',
time: spot.t || spot.time || '',
spotter: spot.sp || spot.spotter
})).slice(0, 20);
return res.json(spots);
} catch (parseErr) {
console.log('DXWatch parse error, trying alternate format');
}
}
} catch (error) {
console.error('DXWatch API error:', error.message);
}
// Try HamQTH DX Cluster as fallback
try {
const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=25', {
headers: { 'User-Agent': 'OpenHamClock/3.0' },
timeout: 5000
});
if (response.ok) {
const text = await response.text();
const lines = text.trim().split('\n');
const spots = lines.slice(0, 20).map(line => {
const parts = line.split(',');
return {
freq: parts[1] ? (parseFloat(parts[1]) / 1000).toFixed(3) : '0.000',
call: parts[2] || 'UNKNOWN',
comment: parts[5] || '',
time: parts[4] ? parts[4].substring(0, 5) + 'z' : '',
spotter: parts[3] || ''
};
}).filter(s => s.call !== 'UNKNOWN');
if (spots.length > 0) {
return res.json(spots);
}
}
} catch (error) {
console.error('HamQTH DX Cluster error:', error.message);
}
// Try DX Summit RSS as another fallback
try {
const response = await fetch('https://www.dxsummit.fi/api/v1/spots?limit=25', {
headers: { 'User-Agent': 'OpenHamClock/3.0' },
timeout: 5000
});
if (response.ok) {
const data = await response.json();
res.json(data);
} else {
// Return empty array if API unavailable
res.json([]);
const spots = data.map(spot => ({
freq: spot.frequency ? (parseFloat(spot.frequency) / 1000).toFixed(3) : '0.000',
call: spot.dx_call || spot.callsign,
comment: spot.info || spot.comment || '',
time: spot.time ? spot.time.substring(11, 16) + 'z' : '',
spotter: spot.spotter || ''
})).slice(0, 20);
if (spots.length > 0) {
return res.json(spots);
}
}
} catch (error) {
console.error('DX Cluster API error:', error.message);
res.json([]); // Return empty array on error
console.error('DX Summit API error:', error.message);
}
// Return empty array if all sources fail
res.json([]);
});
// QRZ Callsign lookup (requires API key)

Loading…
Cancel
Save

Powered by TurnKey Linux.