diff --git a/src/components/DXFilterManager.jsx b/src/components/DXFilterManager.jsx index 90bd77f..9b642bf 100644 --- a/src/components/DXFilterManager.jsx +++ b/src/components/DXFilterManager.jsx @@ -6,6 +6,8 @@ import React, { useState } from 'react'; export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => { const [activeTab, setActiveTab] = useState('zones'); + const [newWatchlistCall, setNewWatchlistCall] = useState(''); + const [newExcludeCall, setNewExcludeCall] = useState(''); if (!isOpen) return null; @@ -93,6 +95,26 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => fontFamily: 'JetBrains Mono, monospace' }); + const addToWatchlist = () => { + if (newWatchlistCall.trim()) { + const current = filters?.watchlist || []; + if (!current.includes(newWatchlistCall.toUpperCase())) { + onFilterChange({ ...filters, watchlist: [...current, newWatchlistCall.toUpperCase()] }); + } + setNewWatchlistCall(''); + } + }; + + const addToExclude = () => { + if (newExcludeCall.trim()) { + const current = filters?.excludeList || []; + if (!current.includes(newExcludeCall.toUpperCase())) { + onFilterChange({ ...filters, excludeList: [...current, newExcludeCall.toUpperCase()] }); + } + setNewExcludeCall(''); + } + }; + const renderZonesTab = () => (
{/* Continents */} @@ -205,115 +227,91 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
); - const renderWatchlistTab = () => { - const [newCall, setNewCall] = useState(''); - const addToWatchlist = () => { - if (newCall.trim()) { - const current = filters?.watchlist || []; - if (!current.includes(newCall.toUpperCase())) { - onFilterChange({ ...filters, watchlist: [...current, newCall.toUpperCase()] }); - } - setNewCall(''); - } - }; - return ( -
-
-
- Watchlist - Highlight these callsigns -
-
- setNewCall(e.target.value.toUpperCase())} - onKeyPress={(e) => e.key === 'Enter' && addToWatchlist()} - placeholder="Enter callsign..." - style={{ - flex: 1, - padding: '8px 12px', - background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', - borderRadius: '4px', - color: 'var(--text-primary)', - fontSize: '13px', - fontFamily: 'JetBrains Mono' - }} - /> - -
-
-
- {(filters?.watchlist || []).map(call => ( -
- {call} - -
- ))} + const renderWatchlistTab = () => ( +
+
+
+ Watchlist - Highlight these callsigns
-
- +
+ setNewWatchlistCall(e.target.value.toUpperCase())} + onKeyPress={(e) => e.key === 'Enter' && addToWatchlist()} + placeholder="Enter callsign..." + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'JetBrains Mono' + }} + /> +
- ); - }; - - const renderExcludeTab = () => { - const [newCall, setNewCall] = useState(''); - const addToExclude = () => { - if (newCall.trim()) { - const current = filters?.excludeList || []; - if (!current.includes(newCall.toUpperCase())) { - onFilterChange({ ...filters, excludeList: [...current, newCall.toUpperCase()] }); - } - setNewCall(''); - } - }; - return ( -
-
-
- Exclude List - Hide these callsigns -
-
- setNewCall(e.target.value.toUpperCase())} - onKeyPress={(e) => e.key === 'Enter' && addToExclude()} - placeholder="Enter callsign..." - style={{ - flex: 1, - padding: '8px 12px', - background: 'var(--bg-tertiary)', - border: '1px solid var(--border-color)', - borderRadius: '4px', - color: 'var(--text-primary)', - fontSize: '13px', - fontFamily: 'JetBrains Mono' - }} - /> - +
+ {(filters?.watchlist || []).map(call => ( +
+ {call} +
+ ))} +
+
+ +
+
+ ); + + const renderExcludeTab = () => ( +
+
+
+ Exclude List - Hide these callsigns
-
- {(filters?.excludeList || []).map(call => ( -
- {call} - -
- ))} +
+ setNewExcludeCall(e.target.value.toUpperCase())} + onKeyPress={(e) => e.key === 'Enter' && addToExclude()} + placeholder="Enter callsign..." + style={{ + flex: 1, + padding: '8px 12px', + background: 'var(--bg-tertiary)', + border: '1px solid var(--border-color)', + borderRadius: '4px', + color: 'var(--text-primary)', + fontSize: '13px', + fontFamily: 'JetBrains Mono' + }} + /> +
- ); - }; +
+ {(filters?.excludeList || []).map(call => ( +
+ {call} + +
+ ))} +
+
+ ); return (
${path.dxCall}
`, - iconAnchor: [0, -10] + html: `${path.dxCall}`, + iconSize: null, + iconAnchor: [0, 0] }); const label = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }).addTo(map); dxPathsMarkersRef.current.push(label); @@ -316,8 +317,9 @@ export const WorldMap = ({ if (spot.lat && spot.lon) { const icon = L.divIcon({ className: '', - html: `
${spot.call}
`, - iconAnchor: [20, 10] + html: `${spot.call}`, + iconSize: null, + iconAnchor: [0, 0] }); const marker = L.marker([spot.lat, spot.lon], { icon }) .bindPopup(`${spot.call}
${spot.ref}
${spot.freq} ${spot.mode}`) @@ -328,7 +330,7 @@ export const WorldMap = ({ } }, [potaSpots, showPOTA]); - // Update satellite markers + // Update satellite markers with orbit tracks useEffect(() => { if (!mapInstanceRef.current) return; const map = mapInstanceRef.current; @@ -340,14 +342,70 @@ export const WorldMap = ({ if (showSatellites && satellites && satellites.length > 0) { satellites.forEach(sat => { + // Draw orbit track if available + if (sat.track && sat.track.length > 1) { + // Split track into segments to handle date line crossing + let segments = []; + let currentSegment = [sat.track[0]]; + + for (let i = 1; i < sat.track.length; i++) { + const prevLon = sat.track[i-1][1]; + const currLon = sat.track[i][1]; + // If longitude jumps more than 180 degrees, start new segment + if (Math.abs(currLon - prevLon) > 180) { + segments.push(currentSegment); + currentSegment = []; + } + currentSegment.push(sat.track[i]); + } + segments.push(currentSegment); + + // Draw each segment + segments.forEach(segment => { + if (segment.length > 1) { + const trackLine = L.polyline(segment, { + color: sat.visible ? '#00ffff' : '#006688', + weight: 2, + opacity: sat.visible ? 0.8 : 0.4, + dashArray: sat.visible ? null : '5, 5' + }).addTo(map); + satTracksRef.current.push(trackLine); + } + }); + } + + // Draw footprint circle if available + if (sat.footprintRadius && sat.lat && sat.lon) { + const footprint = L.circle([sat.lat, sat.lon], { + radius: sat.footprintRadius * 1000, // Convert km to meters + color: '#00ffff', + weight: 1, + opacity: 0.5, + fillColor: '#00ffff', + fillOpacity: 0.1 + }).addTo(map); + satTracksRef.current.push(footprint); + } + + // Add satellite marker icon const icon = L.divIcon({ className: '', - html: `
🛰 ${sat.name}
`, - iconAnchor: [25, 12] + html: `🛰 ${sat.name}`, + iconSize: null, + iconAnchor: [0, 0] }); const marker = L.marker([sat.lat, sat.lon], { icon }) - .bindPopup(`🛰 ${sat.name}
Alt: ${sat.alt} km
Az: ${sat.azimuth}° El: ${sat.elevation}°`) + .bindPopup(` + 🛰 ${sat.name}
+ + + + + + +
Alt:${sat.alt} km
Az:${sat.azimuth}°
El:${sat.elevation}°
Range:${sat.range} km
Status:${sat.visible ? 'Visible' : 'Below horizon'}
+ `) .addTo(map); satMarkersRef.current.push(marker); }); diff --git a/src/hooks/useSatellites.js b/src/hooks/useSatellites.js index 9b8a23d..d3d7499 100644 --- a/src/hooks/useSatellites.js +++ b/src/hooks/useSatellites.js @@ -1,6 +1,7 @@ /** * useSatellites Hook * Tracks amateur radio satellites using TLE data and satellite.js + * Includes orbit track prediction */ import { useState, useEffect, useCallback } from 'react'; import * as satellite from 'satellite.js'; @@ -43,7 +44,7 @@ export const useSatellites = (observerLocation) => { return () => clearInterval(interval); }, []); - // Calculate satellite positions + // Calculate satellite positions and orbits const calculatePositions = useCallback(() => { if (!observerLocation || Object.keys(tleData).length === 0) { setLoading(false); @@ -62,7 +63,7 @@ export const useSatellites = (observerLocation) => { }; Object.entries(tleData).forEach(([name, tle]) => { - // Server returns tle1/tle2, handle both formats + // Handle both line1/line2 and tle1/tle2 formats const line1 = tle.line1 || tle.tle1; const line2 = tle.line2 || tle.tle2; if (!line1 || !line2) return; @@ -94,6 +95,29 @@ export const useSatellites = (observerLocation) => { // Only include if above horizon or popular sat const isPopular = AMATEUR_SATS.some(s => name.includes(s)); if (elevation > -5 || isPopular) { + // Calculate orbit track (past 45 min and future 45 min = 90 min total) + const track = []; + const trackMinutes = 90; + const stepMinutes = 1; + + for (let m = -trackMinutes/2; m <= trackMinutes/2; m += stepMinutes) { + const trackTime = new Date(now.getTime() + m * 60 * 1000); + const trackPV = satellite.propagate(satrec, trackTime); + + if (trackPV.position) { + const trackGmst = satellite.gstime(trackTime); + const trackGd = satellite.eciToGeodetic(trackPV.position, trackGmst); + const trackLat = satellite.degreesLat(trackGd.latitude); + const trackLon = satellite.degreesLong(trackGd.longitude); + track.push([trackLat, trackLon]); + } + } + + // Calculate footprint radius (visibility circle) + // Formula: radius = Earth_radius * arccos(Earth_radius / (Earth_radius + altitude)) + const earthRadius = 6371; // km + const footprintRadius = earthRadius * Math.acos(earthRadius / (earthRadius + alt)); + positions.push({ name, lat, @@ -103,7 +127,9 @@ export const useSatellites = (observerLocation) => { elevation: Math.round(elevation), range: Math.round(rangeSat), visible: elevation > 0, - isPopular + isPopular, + track, + footprintRadius: Math.round(footprintRadius) }); } } catch (e) { @@ -113,7 +139,7 @@ export const useSatellites = (observerLocation) => { // Sort by elevation (highest first) and limit positions.sort((a, b) => b.elevation - a.elevation); - setData(positions.slice(0, 20)); + setData(positions.slice(0, 15)); setLoading(false); } catch (err) { console.error('Satellite calculation error:', err);