fix sat paths, callsign blocks, filters

pull/6/head
accius 3 days ago
parent a19be94768
commit 1529207e7d

@ -6,6 +6,8 @@ import React, { useState } from 'react';
export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => { export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) => {
const [activeTab, setActiveTab] = useState('zones'); const [activeTab, setActiveTab] = useState('zones');
const [newWatchlistCall, setNewWatchlistCall] = useState('');
const [newExcludeCall, setNewExcludeCall] = useState('');
if (!isOpen) return null; if (!isOpen) return null;
@ -93,6 +95,26 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
fontFamily: 'JetBrains Mono, monospace' 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 = () => ( const renderZonesTab = () => (
<div> <div>
{/* Continents */} {/* Continents */}
@ -205,115 +227,91 @@ export const DXFilterManager = ({ filters, onFilterChange, isOpen, onClose }) =>
</div> </div>
); );
const renderWatchlistTab = () => { const renderWatchlistTab = () => (
const [newCall, setNewCall] = useState(''); <div>
const addToWatchlist = () => { <div style={{ marginBottom: '16px' }}>
if (newCall.trim()) { <div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
const current = filters?.watchlist || []; Watchlist - Highlight these callsigns
if (!current.includes(newCall.toUpperCase())) {
onFilterChange({ ...filters, watchlist: [...current, newCall.toUpperCase()] });
}
setNewCall('');
}
};
return (
<div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Watchlist - Highlight these callsigns
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={newCall}
onChange={(e) => 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'
}}
/>
<button onClick={addToWatchlist} style={{ padding: '8px 16px', background: 'var(--accent-cyan)', border: 'none', borderRadius: '4px', color: '#000', fontWeight: '600', cursor: 'pointer' }}>Add</button>
</div>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
{(filters?.watchlist || []).map(call => (
<div key={call} style={{ ...chipStyle(true), display: 'flex', alignItems: 'center', gap: '8px' }}>
{call}
<button onClick={() => toggleArrayItem('watchlist', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>×</button>
</div>
))}
</div> </div>
<div style={{ marginTop: '16px' }}> <div style={{ display: 'flex', gap: '8px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', fontSize: '12px', cursor: 'pointer' }}> <input
<input type="text"
type="checkbox" value={newWatchlistCall}
checked={filters?.watchlistOnly || false} onChange={(e) => setNewWatchlistCall(e.target.value.toUpperCase())}
onChange={(e) => onFilterChange({ ...filters, watchlistOnly: e.target.checked || undefined })} onKeyPress={(e) => e.key === 'Enter' && addToWatchlist()}
/> placeholder="Enter callsign..."
Show only watchlist callsigns style={{
</label> 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'
}}
/>
<button onClick={addToWatchlist} style={{ padding: '8px 16px', background: 'var(--accent-cyan)', border: 'none', borderRadius: '4px', color: '#000', fontWeight: '600', cursor: 'pointer' }}>Add</button>
</div> </div>
</div> </div>
); <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
}; {(filters?.watchlist || []).map(call => (
<div key={call} style={{ ...chipStyle(true), display: 'flex', alignItems: 'center', gap: '8px' }}>
const renderExcludeTab = () => { {call}
const [newCall, setNewCall] = useState(''); <button onClick={() => toggleArrayItem('watchlist', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>×</button>
const addToExclude = () => {
if (newCall.trim()) {
const current = filters?.excludeList || [];
if (!current.includes(newCall.toUpperCase())) {
onFilterChange({ ...filters, excludeList: [...current, newCall.toUpperCase()] });
}
setNewCall('');
}
};
return (
<div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Exclude List - Hide these callsigns
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<input
type="text"
value={newCall}
onChange={(e) => 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'
}}
/>
<button onClick={addToExclude} style={{ padding: '8px 16px', background: 'var(--accent-red)', border: 'none', borderRadius: '4px', color: '#fff', fontWeight: '600', cursor: 'pointer' }}>Add</button>
</div> </div>
))}
</div>
<div style={{ marginTop: '16px' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: '8px', color: 'var(--text-secondary)', fontSize: '12px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={filters?.watchlistOnly || false}
onChange={(e) => onFilterChange({ ...filters, watchlistOnly: e.target.checked || undefined })}
/>
Show only watchlist callsigns
</label>
</div>
</div>
);
const renderExcludeTab = () => (
<div>
<div style={{ marginBottom: '16px' }}>
<div style={{ fontSize: '13px', fontWeight: '600', color: 'var(--text-primary)', marginBottom: '8px' }}>
Exclude List - Hide these callsigns
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}> <div style={{ display: 'flex', gap: '8px' }}>
{(filters?.excludeList || []).map(call => ( <input
<div key={call} style={{ ...chipStyle(false), background: 'rgba(255, 68, 68, 0.2)', borderColor: 'var(--accent-red)', color: 'var(--accent-red)', display: 'flex', alignItems: 'center', gap: '8px' }}> type="text"
{call} value={newExcludeCall}
<button onClick={() => toggleArrayItem('excludeList', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>×</button> onChange={(e) => setNewExcludeCall(e.target.value.toUpperCase())}
</div> 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'
}}
/>
<button onClick={addToExclude} style={{ padding: '8px 16px', background: 'var(--accent-red)', border: 'none', borderRadius: '4px', color: '#fff', fontWeight: '600', cursor: 'pointer' }}>Add</button>
</div> </div>
</div> </div>
); <div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
}; {(filters?.excludeList || []).map(call => (
<div key={call} style={{ ...chipStyle(false), background: 'rgba(255, 68, 68, 0.2)', borderColor: 'var(--accent-red)', color: 'var(--accent-red)', display: 'flex', alignItems: 'center', gap: '8px' }}>
{call}
<button onClick={() => toggleArrayItem('excludeList', call)} style={{ background: 'none', border: 'none', color: 'var(--accent-red)', cursor: 'pointer', padding: 0, fontSize: '14px' }}>×</button>
</div>
))}
</div>
</div>
);
return ( return (
<div style={{ <div style={{

@ -290,8 +290,9 @@ export const WorldMap = ({
if (showDXLabels || isHovered) { if (showDXLabels || isHovered) {
const labelIcon = L.divIcon({ const labelIcon = L.divIcon({
className: '', className: '',
html: `<div style="background: ${isHovered ? '#fff' : color}; color: ${isHovered ? color : '#000'}; padding: 3px 8px; border-radius: 4px; font-family: JetBrains Mono; font-size: 11px; font-weight: 700; white-space: nowrap; border: 1px solid ${isHovered ? color : 'rgba(0,0,0,0.3)'};">${path.dxCall}</div>`, html: `<span style="display:inline-block;background:${isHovered ? '#fff' : color};color:${isHovered ? color : '#000'};padding:4px 8px;border-radius:4px;font-family:'JetBrains Mono',monospace;font-size:12px;font-weight:700;white-space:nowrap;border:2px solid ${isHovered ? color : 'rgba(0,0,0,0.5)'};box-shadow:0 2px 4px rgba(0,0,0,0.4);">${path.dxCall}</span>`,
iconAnchor: [0, -10] iconSize: null,
iconAnchor: [0, 0]
}); });
const label = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }).addTo(map); const label = L.marker([path.dxLat, path.dxLon], { icon: labelIcon, interactive: false }).addTo(map);
dxPathsMarkersRef.current.push(label); dxPathsMarkersRef.current.push(label);
@ -316,8 +317,9 @@ export const WorldMap = ({
if (spot.lat && spot.lon) { if (spot.lat && spot.lon) {
const icon = L.divIcon({ const icon = L.divIcon({
className: '', className: '',
html: `<div style="background: #aa66ff; color: white; padding: 2px 6px; border-radius: 4px; font-size: 12px; font-family: JetBrains Mono; white-space: nowrap; border: 1px solid white;">${spot.call}</div>`, html: `<span style="display:inline-block;background:#aa66ff;color:#fff;padding:4px 8px;border-radius:4px;font-size:12px;font-family:'JetBrains Mono',monospace;font-weight:700;white-space:nowrap;border:2px solid #fff;box-shadow:0 2px 4px rgba(0,0,0,0.4);">${spot.call}</span>`,
iconAnchor: [20, 10] iconSize: null,
iconAnchor: [0, 0]
}); });
const marker = L.marker([spot.lat, spot.lon], { icon }) const marker = L.marker([spot.lat, spot.lon], { icon })
.bindPopup(`<b>${spot.call}</b><br>${spot.ref}<br>${spot.freq} ${spot.mode}`) .bindPopup(`<b>${spot.call}</b><br>${spot.ref}<br>${spot.freq} ${spot.mode}`)
@ -328,7 +330,7 @@ export const WorldMap = ({
} }
}, [potaSpots, showPOTA]); }, [potaSpots, showPOTA]);
// Update satellite markers // Update satellite markers with orbit tracks
useEffect(() => { useEffect(() => {
if (!mapInstanceRef.current) return; if (!mapInstanceRef.current) return;
const map = mapInstanceRef.current; const map = mapInstanceRef.current;
@ -340,14 +342,70 @@ export const WorldMap = ({
if (showSatellites && satellites && satellites.length > 0) { if (showSatellites && satellites && satellites.length > 0) {
satellites.forEach(sat => { 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({ const icon = L.divIcon({
className: '', className: '',
html: `<div style="background: #00ffff; color: #000; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-family: JetBrains Mono; white-space: nowrap; border: 2px solid ${sat.visible ? '#fff' : 'rgba(255,255,255,0.3)'}; font-weight: bold; opacity: ${sat.visible ? 1 : 0.6};">🛰 ${sat.name}</div>`, html: `<span style="display:inline-block;background:${sat.visible ? '#00ffff' : '#006688'};color:${sat.visible ? '#000' : '#fff'};padding:4px 8px;border-radius:4px;font-size:11px;font-family:'JetBrains Mono',monospace;white-space:nowrap;border:2px solid ${sat.visible ? '#fff' : '#00aaaa'};font-weight:bold;box-shadow:0 2px 4px rgba(0,0,0,0.4);">🛰 ${sat.name}</span>`,
iconAnchor: [25, 12] iconSize: null,
iconAnchor: [0, 0]
}); });
const marker = L.marker([sat.lat, sat.lon], { icon }) const marker = L.marker([sat.lat, sat.lon], { icon })
.bindPopup(`<b>🛰 ${sat.name}</b><br>Alt: ${sat.alt} km<br>Az: ${sat.azimuth}° El: ${sat.elevation}°`) .bindPopup(`
<b>🛰 ${sat.name}</b><br>
<table style="font-size: 11px;">
<tr><td>Alt:</td><td>${sat.alt} km</td></tr>
<tr><td>Az:</td><td>${sat.azimuth}°</td></tr>
<tr><td>El:</td><td>${sat.elevation}°</td></tr>
<tr><td>Range:</td><td>${sat.range} km</td></tr>
<tr><td>Status:</td><td>${sat.visible ? '<span style="color:green">Visible</span>' : '<span style="color:gray">Below horizon</span>'}</td></tr>
</table>
`)
.addTo(map); .addTo(map);
satMarkersRef.current.push(marker); satMarkersRef.current.push(marker);
}); });

@ -1,6 +1,7 @@
/** /**
* useSatellites Hook * useSatellites Hook
* Tracks amateur radio satellites using TLE data and satellite.js * Tracks amateur radio satellites using TLE data and satellite.js
* Includes orbit track prediction
*/ */
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import * as satellite from 'satellite.js'; import * as satellite from 'satellite.js';
@ -43,7 +44,7 @@ export const useSatellites = (observerLocation) => {
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
// Calculate satellite positions // Calculate satellite positions and orbits
const calculatePositions = useCallback(() => { const calculatePositions = useCallback(() => {
if (!observerLocation || Object.keys(tleData).length === 0) { if (!observerLocation || Object.keys(tleData).length === 0) {
setLoading(false); setLoading(false);
@ -62,7 +63,7 @@ export const useSatellites = (observerLocation) => {
}; };
Object.entries(tleData).forEach(([name, tle]) => { 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 line1 = tle.line1 || tle.tle1;
const line2 = tle.line2 || tle.tle2; const line2 = tle.line2 || tle.tle2;
if (!line1 || !line2) return; if (!line1 || !line2) return;
@ -94,6 +95,29 @@ export const useSatellites = (observerLocation) => {
// Only include if above horizon or popular sat // Only include if above horizon or popular sat
const isPopular = AMATEUR_SATS.some(s => name.includes(s)); const isPopular = AMATEUR_SATS.some(s => name.includes(s));
if (elevation > -5 || isPopular) { 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({ positions.push({
name, name,
lat, lat,
@ -103,7 +127,9 @@ export const useSatellites = (observerLocation) => {
elevation: Math.round(elevation), elevation: Math.round(elevation),
range: Math.round(rangeSat), range: Math.round(rangeSat),
visible: elevation > 0, visible: elevation > 0,
isPopular isPopular,
track,
footprintRadius: Math.round(footprintRadius)
}); });
} }
} catch (e) { } catch (e) {
@ -113,7 +139,7 @@ export const useSatellites = (observerLocation) => {
// Sort by elevation (highest first) and limit // Sort by elevation (highest first) and limit
positions.sort((a, b) => b.elevation - a.elevation); positions.sort((a, b) => b.elevation - a.elevation);
setData(positions.slice(0, 20)); setData(positions.slice(0, 15));
setLoading(false); setLoading(false);
} catch (err) { } catch (err) {
console.error('Satellite calculation error:', err); console.error('Satellite calculation error:', err);

Loading…
Cancel
Save

Powered by TurnKey Linux.