From 66f3cf0a57a7418854ce038a735761ef6f9685bb Mon Sep 17 00:00:00 2001 From: accius Date: Fri, 30 Jan 2026 00:06:35 -0500 Subject: [PATCH] init commit --- package.json | 2 +- public/index.html | 418 ++++++++++++++++++++++++++++++++++++++++++---- server.js | 89 +++++++++- 3 files changed, 469 insertions(+), 40 deletions(-) diff --git a/package.json b/package.json index e7c564a..fb9b221 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/public/index.html b/public/index.html index 6fda73b..2e3f692 100644 --- a/public/index.html +++ b/public/index.html @@ -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 }) => (
OpenHamClock -
{callsign}
+
{callsign}
UPTIME: {uptime} v{version} +
@@ -847,24 +933,288 @@ ); + // ============================================ + // 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 ( +
+
e.stopPropagation()}> +

+ ⚙ Station Settings +

+ + {/* Callsign */} +
+ + 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' + }} + /> +
+ + {/* Grid Square */} +
+ + 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' + }} + /> +
+ + {/* Lat/Lon */} +
+
+ + 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' + }} + /> +
+
+ + 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' + }} + /> +
+
+ + {/* Get Location Button */} + + + {/* Buttons */} +
+ + +
+ +

+ Settings are saved in your browser +

+
+
+ ); + }; + // ============================================ // 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 (
-
+
setShowSettings(true)} />
{/* Row 1 */} - + {/* Row 2: Map */}
- +
Click anywhere on map to set DX location • Use buttons to change map style
- +
@@ -917,8 +1267,16 @@
- 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}
+ + {/* Settings Panel */} + setShowSettings(false)} + config={config} + onSave={handleSaveConfig} + />
); }; diff --git a/server.js b/server.js index 0092bc2..7e5afaf 100644 --- a/server.js +++ b/server.js @@ -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)