diff --git a/public/index.html b/public/index.html index 8329532..bcc7355 100644 --- a/public/index.html +++ b/public/index.html @@ -429,7 +429,6 @@ // Great circle path for Leaflet const getGreatCirclePoints = (lat1, lon1, lat2, lon2, n = 100) => { - const points = []; const toRad = d => d * Math.PI / 180; const toDeg = r => r * 180 / Math.PI; @@ -440,6 +439,12 @@ Math.sin((φ1-φ2)/2)**2 + Math.cos(φ1)*Math.cos(φ2)*Math.sin((λ1-λ2)/2)**2 )); + // If distance is essentially zero, return just the two points + if (d < 0.0001) { + return [[lat1, lon1], [lat2, lon2]]; + } + + const rawPoints = []; for (let i = 0; i <= n; i++) { const f = i / n; const A = Math.sin((1-f)*d) / Math.sin(d); @@ -447,9 +452,29 @@ const x = A*Math.cos(φ1)*Math.cos(λ1) + B*Math.cos(φ2)*Math.cos(λ2); const y = A*Math.cos(φ1)*Math.sin(λ1) + B*Math.cos(φ2)*Math.sin(λ2); const z = A*Math.sin(φ1) + B*Math.sin(φ2); - points.push([toDeg(Math.atan2(z, Math.sqrt(x*x+y*y))), toDeg(Math.atan2(y, x))]); + rawPoints.push([toDeg(Math.atan2(z, Math.sqrt(x*x+y*y))), toDeg(Math.atan2(y, x))]); + } + + // Split path at antimeridian crossings for proper Leaflet rendering + const segments = []; + let currentSegment = [rawPoints[0]]; + + for (let i = 1; i < rawPoints.length; i++) { + const prevLon = rawPoints[i-1][1]; + const currLon = rawPoints[i][1]; + + // Check if we crossed the antimeridian (lon jumps more than 180°) + if (Math.abs(currLon - prevLon) > 180) { + // Finish current segment + segments.push(currentSegment); + // Start new segment + currentSegment = []; + } + currentSegment.push(rawPoints[i]); } - return points; + segments.push(currentSegment); + + return segments; }; // ============================================ @@ -832,7 +857,13 @@ if (deMarkerRef.current) map.removeLayer(deMarkerRef.current); if (dxMarkerRef.current) map.removeLayer(dxMarkerRef.current); if (sunMarkerRef.current) map.removeLayer(sunMarkerRef.current); - if (pathRef.current) map.removeLayer(pathRef.current); + if (pathRef.current) { + if (Array.isArray(pathRef.current)) { + pathRef.current.forEach(p => map.removeLayer(p)); + } else { + map.removeLayer(pathRef.current); + } + } // DE Marker const deIcon = L.divIcon({ @@ -868,14 +899,29 @@ .bindPopup('Subsolar Point') .addTo(map); - // Great circle path - const pathPoints = getGreatCirclePoints(deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon); - pathRef.current = L.polyline(pathPoints, { - color: '#00ddff', - weight: 3, - opacity: 0.8, - dashArray: '10, 6' - }).addTo(map); + // Great circle path (handles antimeridian crossing) + const pathSegments = getGreatCirclePoints(deLocation.lat, deLocation.lon, dxLocation.lat, dxLocation.lon); + + // pathRef now holds an array of polylines for each segment + if (!Array.isArray(pathRef.current)) { + pathRef.current = []; + } + // Clear old paths + pathRef.current.forEach(p => map.removeLayer(p)); + pathRef.current = []; + + // Draw each segment + pathSegments.forEach(segment => { + if (segment.length > 1) { + const polyline = L.polyline(segment, { + color: '#00ddff', + weight: 3, + opacity: 0.8, + dashArray: '10, 6' + }).addTo(map); + pathRef.current.push(polyline); + } + }); }, [deLocation, dxLocation]); diff --git a/server.js b/server.js index 951311e..595c2e0 100644 --- a/server.js +++ b/server.js @@ -119,14 +119,55 @@ app.get('/api/hamqsl/conditions', async (req, res) => { app.get('/api/dxcluster/spots', async (req, res) => { console.log('[DX Cluster] Fetching spots...'); - // Try DX Heat API first (most reliable) + // Try DXCluster.co API first (very reliable JSON API) try { - const response = await fetch('https://dxheat.com/dxc/data.php?include_modes=cw,ssb,ft8,ft4,rtty&include_bands=160,80,60,40,30,20,17,15,12,10,6&limit=30', { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch('https://dxcluster.co/api/v1/spots?limit=30', { headers: { - 'User-Agent': 'OpenHamClock/3.1', + 'User-Agent': 'OpenHamClock/3.3', 'Accept': 'application/json' + }, + signal: controller.signal + }); + clearTimeout(timeout); + + if (response.ok) { + const data = await response.json(); + console.log('[DX Cluster] dxcluster.co returned', data.length, 'spots'); + if (data && data.length > 0) { + const spots = data.slice(0, 20).map(spot => ({ + freq: spot.frequency ? (parseFloat(spot.frequency) / 1000).toFixed(3) : '0.000', + call: spot.dx_callsign || spot.callsign || 'UNKNOWN', + comment: spot.comment || '', + time: spot.time ? spot.time.substring(11, 16) + 'z' : '', + spotter: spot.spotter_callsign || '' + })); + return res.json(spots); } + } + } catch (error) { + if (error.name === 'AbortError') { + console.log('[DX Cluster] dxcluster.co timeout'); + } else { + console.error('[DX Cluster] dxcluster.co error:', error.message); + } + } + + // Try DX Heat API + try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch('https://dxheat.com/dxc/data.php?include_modes=cw,ssb,ft8,ft4,rtty&include_bands=160,80,60,40,30,20,17,15,12,10,6&limit=30', { + headers: { + 'User-Agent': 'OpenHamClock/3.3', + 'Accept': 'application/json' + }, + signal: controller.signal }); + clearTimeout(timeout); if (response.ok) { const text = await response.text(); @@ -149,51 +190,58 @@ app.get('/api/dxcluster/spots', async (req, res) => { } } } catch (error) { - console.error('[DX Cluster] DXHeat error:', error.message); + if (error.name === 'AbortError') { + console.log('[DX Cluster] DXHeat timeout'); + } else { + console.error('[DX Cluster] DXHeat error:', error.message); + } } - // Try PSK Reporter as fallback (very reliable) + // Try RBN (Reverse Beacon Network) API try { - const response = await fetch('https://pskreporter.info/cgi-bin/pskquery5.pl?encap=1&callback=0&statistics=0&noactive=1&nolocator=1&rronly=1&flowStartSeconds=-900&limit=30', { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + + const response = await fetch('https://reversebeacon.net/api/spots.php?r=30', { headers: { - 'User-Agent': 'OpenHamClock/3.1' - } + 'User-Agent': 'OpenHamClock/3.3' + }, + signal: controller.signal }); + clearTimeout(timeout); if (response.ok) { - const text = await response.text(); - console.log('[DX Cluster] PSKReporter response length:', text.length); - // PSK Reporter returns XML, parse it - const callMatches = text.match(/senderCallsign="([^"]+)"/g) || []; - const freqMatches = text.match(/frequency="([^"]+)"/g) || []; - const modeMatches = text.match(/mode="([^"]+)"/g) || []; - - if (callMatches.length > 0) { - const spots = callMatches.slice(0, 20).map((match, i) => { - const call = match.replace('senderCallsign="', '').replace('"', ''); - const freq = freqMatches[i] ? (parseFloat(freqMatches[i].replace('frequency="', '').replace('"', '')) / 1000000).toFixed(3) : '0.000'; - const mode = modeMatches[i] ? modeMatches[i].replace('mode="', '').replace('"', '') : ''; - return { - freq: freq, - call: call, - comment: mode, - time: new Date().toISOString().substring(11, 16) + 'z', - spotter: 'PSK' - }; - }); - console.log('[DX Cluster] PSKReporter returned', spots.length, 'spots'); + const data = await response.json(); + console.log('[DX Cluster] RBN returned', data?.length || 0, 'spots'); + if (data && data.length > 0) { + const spots = data.slice(0, 20).map(spot => ({ + freq: spot.freq ? (parseFloat(spot.freq) / 1000).toFixed(3) : '0.000', + call: spot.dx || spot.callsign || 'UNKNOWN', + comment: spot.mode ? `${spot.mode} ${spot.db || ''}dB` : '', + time: spot.time || new Date().toISOString().substring(11, 16) + 'z', + spotter: spot.de || '' + })); return res.json(spots); } } } catch (error) { - console.error('[DX Cluster] PSKReporter error:', error.message); + if (error.name === 'AbortError') { + console.log('[DX Cluster] RBN timeout'); + } else { + console.error('[DX Cluster] RBN error:', error.message); + } } // Try HamQTH DX Cluster try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = await fetch('https://www.hamqth.com/dxc_csv.php?limit=30', { - headers: { 'User-Agent': 'OpenHamClock/3.1' } + headers: { 'User-Agent': 'OpenHamClock/3.3' }, + signal: controller.signal }); + clearTimeout(timeout); if (response.ok) { const text = await response.text(); @@ -219,17 +267,26 @@ app.get('/api/dxcluster/spots', async (req, res) => { } } } catch (error) { - console.error('[DX Cluster] HamQTH error:', error.message); + if (error.name === 'AbortError') { + console.log('[DX Cluster] HamQTH timeout'); + } else { + console.error('[DX Cluster] HamQTH error:', error.message); + } } - // Try DX Watch legacy endpoint + // Try DXWatch as last resort try { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 5000); + const response = await fetch('https://dxwatch.com/dxsd1/s.php?s=0&r=30', { headers: { - 'User-Agent': 'OpenHamClock/3.1', + 'User-Agent': 'OpenHamClock/3.3', 'Accept': '*/*' - } + }, + signal: controller.signal }); + clearTimeout(timeout); if (response.ok) { const text = await response.text(); @@ -252,11 +309,14 @@ app.get('/api/dxcluster/spots', async (req, res) => { } } } catch (error) { - console.error('[DX Cluster] DXWatch error:', error.message); + if (error.name === 'AbortError') { + console.log('[DX Cluster] DXWatch timeout'); + } else { + console.error('[DX Cluster] DXWatch error:', error.message); + } } console.log('[DX Cluster] All sources failed, returning empty'); - // Return empty array if all sources fail res.json([]); });