diff --git a/app_sat.go b/app_sat.go index 8cc4805..d7eab28 100644 --- a/app_sat.go +++ b/app_sat.go @@ -136,6 +136,37 @@ type SatTuning struct { RangeRate float64 `json:"range_rate"` Visible bool `json:"visible"` At time.Time `json:"at"` + + // Where the satellite is over the earth. Carried with the tuning because + // they are read together and change together — the panel would otherwise ask + // twice a second for two halves of one instant. + Lat float64 `json:"lat"` + Lon float64 `json:"lon"` + AltKm float64 `json:"alt_km"` + Footprint float64 `json:"footprint_km"` +} + +// SatPassInfo is the pass in progress, or the next one. +// +// Separate from the tuning and polled far more slowly: predicting a pass steps +// the orbit thirty seconds at a time across hours, which is not something to do +// once a second for a countdown a browser can run itself from two timestamps. +type SatPassInfo struct { + Name string `json:"name"` + HasPass bool `json:"has_pass"` + // InPass distinguishes "it is up now" from "it rises at". The pass in + // progress is reported whatever its maximum elevation: an operator watching + // a satellite go over does not want it hidden because it fell below the + // threshold that filters the TABLE of what is worth waiting for. + InPass bool `json:"in_pass"` + AOS time.Time `json:"aos"` + LOS time.Time `json:"los"` + AOSAz float64 `json:"aos_az"` + LOSAz float64 `json:"los_az"` + MaxEl float64 `json:"max_el"` + MaxElAz float64 `json:"max_el_az"` + MaxElAt time.Time `json:"max_el_at"` + Duration float64 `json:"duration_s"` } // ── Lifecycle ─────────────────────────────────────────────────────────────── @@ -708,6 +739,50 @@ func (a *App) GetSatellitePasses(names []string, hours int) ([]sat.Pass, error) return passes, nil } +// GetSatelliteNextPass is the pass in progress, or the next one to come. +// +// The one question that decides whether an operator sits down at the radio, and +// the reason a satellite tab is worth having at all: how long have I got, and +// how high does it get. +func (a *App) GetSatelliteNextPass(name string) (SatPassInfo, error) { + out := SatPassInfo{Name: name} + obs, err := a.satObserver() + if err != nil { + return out, err + } + real, ok := a.satResolve(name) + if !ok { + return out, fmt.Errorf("%s is not in the element set", name) + } + store, _, _ := a.satParts() + now := time.Now().UTC() + // From a little before now: a pass that started two minutes ago is the one + // the operator is in, and asking from this instant would skip it and report + // the next orbit instead — an hour and a half away, while the satellite is + // overhead. + from := now.Add(-30 * time.Minute) + // Elevation zero, not the operator's minimum. That threshold filters the + // table of passes worth waiting for; it must not hide the pass they are + // actually working. + passes, err := store.Passes(real, obs, from, now.Add(26*time.Hour), 0) + if err != nil { + return out, err + } + for _, p := range passes { + if p.LOS.Before(now) { + continue // already over + } + out.HasPass = true + out.InPass = !p.AOS.After(now) + out.AOS, out.LOS = p.AOS, p.LOS + out.AOSAz, out.LOSAz = p.AOSAz, p.LOSAz + out.MaxEl, out.MaxElAz, out.MaxElAt = p.MaxEl, p.MaxElAz, p.MaxElAt + out.Duration = p.Duration + return out, nil + } + return out, nil +} + // GetSatelliteTuning is the working answer: where to listen, where to transmit, // and where the bird is, for one satellite and one transponder. // @@ -777,6 +852,7 @@ func (a *App) GetSatelliteTuning(name string, transponder int, downHz int64) (Sa sh := sat.Doppler(p, out.NominalDown, out.NominalUp) out.DownHz, out.UpHz = sh.DownHz, sh.UpHz out.Az, out.El, out.RangeKm, out.RangeRate = p.Az, p.El, p.RangeKm, p.RangeRate + out.Lat, out.Lon, out.AltKm, out.Footprint = p.Lat, p.Lon, p.AltKm, p.Footprint out.Visible = p.Visible() return out, nil } diff --git a/changelog.json b/changelog.json index ec8f74e..7ae3617 100644 --- a/changelog.json +++ b/changelog.json @@ -5,12 +5,16 @@ "en": [ "[NEW] Satellites. A new tab (Tools → Satellites) tracks the amateur birds: a map with each satellite's footprint and the selected one's path over the ground, the next passes with their maximum elevation, and — for the satellite you are on — the azimuth, the elevation and the Doppler-corrected downlink and uplink. Orbital elements come from Celestrak (with a mirror behind it) and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. The shipped frequency list covers the FM and linear satellites and QO-100, and lives in a file you can correct yourself when a transponder is switched.", "Doppler tracking drives the radio. Track puts the rig on the satellite and keeps it there, once a second: an IC-9700 or IC-9100 in its own satellite mode, a FlexRadio on two slices (A the downlink, B the uplink, created if they are missing, full duplex on) — and any other radio on the downlink, which it says plainly rather than half-doing the job. Tune the receiver where you like: the tracker reads the dial, takes it as the station you have chosen, and moves the transmitter to match. QSOs made while tracking are logged with the NOMINAL frequencies, SAT_NAME, SAT_MODE and PROP_MODE=SAT — the transponder's own numbers, which both stations can agree on, rather than where one radio happened to be.", - "The antenna follows too. An EasyComm II rotator — what SatPC32, Gpredict and Hamlib speak, so most az/el controllers — is pointed at the satellite while you track, over serial or over the network (Settings → Satellites). It is a separate machine from your HF rotator, so a station with both keeps both. A 450° rotator is used as one: a pass crossing north continues past 360 instead of unwinding through the whole scale with the antenna sweeping the ground. The panel shows where the antenna actually is beside where the satellite is — and says plainly when a controller only accepts commands without reporting back, which many do." + "The antenna follows too. An EasyComm II rotator — what SatPC32, Gpredict and Hamlib speak, so most az/el controllers — is pointed at the satellite while you track, over serial or over the network (Settings → Satellites). It is a separate machine from your HF rotator, so a station with both keeps both. A 450° rotator is used as one: a pass crossing north continues past 360 instead of unwinding through the whole scale with the antenna sweeping the ground. The panel shows where the antenna actually is beside where the satellite is — and says plainly when a controller only accepts commands without reporting back, which many do.", + "Settings → Satellites is where the satellite work is set up, and the tab is what you use during a pass. Choose the satellites you follow the way you choose awards — two columns, search, the ones with a frequency plan first — and the tab and the pass list show those and nothing else. The orbital elements move there too: age, fetch, and paste-your-own. None of it is something to be doing while a bird is going over.", + "The satellite panel says what a pass actually is. A countdown to AOS, or to LOS once it is up, with a bar showing where in the pass you are; rise, peak and set with their compass directions; distance, altitude and footprint; and whether it is approaching or receding, which is why the frequencies move the way they do. The frequencies keep the corrected figure large and the Doppler shift beside it." ], "fr": [ "[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode.", "Le suivi Doppler pilote la radio. « Suivre » met le poste sur le satellite et l'y maintient, chaque seconde : un IC-9700 ou IC-9100 dans son propre mode satellite, un FlexRadio sur deux slices (A la descente, B la montée, créées si elles manquent, full duplex activé) — et n'importe quelle autre radio sur la descente seule, ce qu'elle annonce clairement plutôt que de faire le travail à moitié. Accordez le récepteur où vous voulez : le suivi lit le VFO, y voit la station que vous avez choisie, et déplace l'émetteur en conséquence. Les QSO faits pendant le suivi sont enregistrés avec les fréquences NOMINALES, SAT_NAME, SAT_MODE et PROP_MODE=SAT — les chiffres du transpondeur, sur lesquels les deux stations peuvent s'accorder, plutôt que l'endroit où une radio se trouvait.", - "L'antenne suit aussi. Un rotor EasyComm II — le langage de SatPC32, Gpredict et Hamlib, donc la plupart des contrôleurs az/él — est pointé vers le satellite pendant le suivi, en série ou en réseau (Réglages → Satellites). C'est une machine distincte du rotor HF : une station qui a les deux garde les deux. Un rotor 450° est utilisé comme tel : un passage qui traverse le nord continue au-delà de 360 au lieu de se dérouler sur toute la course, antenne balayant le sol. Le panneau montre où l'antenne se trouve réellement à côté de la position du satellite — et dit clairement quand un contrôleur se contente d'accepter les commandes sans répondre, ce que beaucoup font." + "L'antenne suit aussi. Un rotor EasyComm II — le langage de SatPC32, Gpredict et Hamlib, donc la plupart des contrôleurs az/él — est pointé vers le satellite pendant le suivi, en série ou en réseau (Réglages → Satellites). C'est une machine distincte du rotor HF : une station qui a les deux garde les deux. Un rotor 450° est utilisé comme tel : un passage qui traverse le nord continue au-delà de 360 au lieu de se dérouler sur toute la course, antenne balayant le sol. Le panneau montre où l'antenne se trouve réellement à côté de la position du satellite — et dit clairement quand un contrôleur se contente d'accepter les commandes sans répondre, ce que beaucoup font.", + "Réglages → Satellites, c'est là qu'on configure ; l'onglet, c'est ce qu'on utilise pendant un passage. On choisit les satellites suivis comme on choisit les diplômes — deux colonnes, recherche, ceux qui ont un plan de fréquences d'abord — et l'onglet comme la liste des passages n'affichent que ceux-là. Les éléments orbitaux y passent aussi : âge, récupération, et collage des siens. Rien de tout cela ne se fait pendant qu'un satellite passe.", + "Le panneau satellite dit enfin ce qu'est un passage. Un compte à rebours jusqu'à l'AOS, ou jusqu'au LOS une fois qu'il est levé, avec une barre montrant où l'on en est ; lever, culmination et coucher avec leurs directions à la boussole ; distance, altitude et empreinte ; et s'il se rapproche ou s'éloigne, ce qui explique le sens du décalage. Les fréquences gardent la valeur corrigée en grand et le Doppler à côté." ] }, { diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx index a45c1a3..f80555e 100644 --- a/frontend/src/components/SatellitePanel.tsx +++ b/frontend/src/components/SatellitePanel.tsx @@ -1,11 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste, Radio } from 'lucide-react'; +import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown } from 'lucide-react'; import { GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, - GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, - GetSatSettings, SaveSatSettings, + GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, } from '../../wailsjs/go/main/App'; import { EventsOn } from '../../wailsjs/runtime/runtime'; @@ -18,11 +17,11 @@ import { useI18n } from '@/lib/i18n'; // Satellites — where the birds are, and what to do with the radio. // -// The tab answers three questions at once because on a pass there is no time to -// go looking for any of them: where the satellite is (the map), when the next -// one comes (the table), and what to tune (the readout). Everything is computed -// in Go from the same element set, so the dial and the map can never tell -// different stories. +// The tab answers the three questions a pass poses, and answers them where they +// are asked: how long have I got (the countdown), where is it (the map), what +// do I tune (the readout). Everything else — which satellites to follow, where +// the elements come from, the rotator — is maintenance and lives in Settings. +// During a pass there is no time to configure anything. type Bird = { name: string; norad: number; geostationary: boolean; favorite: boolean; @@ -40,11 +39,17 @@ type Pass = { name: string; aos: string; los: string; aos_az: number; los_az: number; max_el: number; max_el_az: number; max_el_at: string; duration_s: number; }; +type PassInfo = { + name: string; has_pass: boolean; in_pass: boolean; + aos: string; los: string; aos_az: number; los_az: number; + max_el: number; max_el_az: number; max_el_at: string; duration_s: number; +}; type Tuning = { name: string; transponder: string; mode: string; nominal_down: number; nominal_up: number; down_hz: number; up_hz: number; ctcss: number; inverting: boolean; az: number; el: number; range_km: number; range_rate: number; visible: boolean; + lat: number; lon: number; alt_km: number; footprint_km: number; }; type Track = { on: boolean; name: string; transponder: string; mode: string; @@ -57,25 +62,40 @@ type Track = { const MAP_VIEW_SAT = 'opslog.satMapView'; -// Above the horizon is green, below is grey. Nothing subtler: on a pass the one -// thing an operator needs to read from across the room is whether the bird is -// up. -const upColour = (up: boolean) => (up ? 'var(--success)' : 'var(--muted-foreground)'); - const fmtHz = (hz: number) => { if (!hz) return '—'; - const mhz = hz / 1e6; // Six decimals: a linear transponder is tuned to the hundred hertz, and the // Doppler correction moves the last three digits every second. - return mhz.toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 '); + return (hz / 1e6).toFixed(6).replace(/(\d)(?=(\d{3})+\.)/g, '$1 '); }; const fmtDeg = (d: number) => `${d.toFixed(1)}°`; +const fmtKm = (km: number) => `${Math.round(km).toLocaleString()} km`; const hhmm = (iso: string) => { const d = new Date(iso); return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16); }; +const hhmmss = (iso: string) => { + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 19); +}; const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000); +// A countdown an operator can act on. Seconds while they matter, then minutes, +// then hours — nobody needs "1h 04m 37s", and nobody wants "0m" for the last +// fifty seconds before a satellite rises. +function fmtCountdown(ms: number): string { + const s = Math.max(0, Math.round(ms / 1000)); + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`; + const h = Math.floor(s / 3600); + return `${h}h ${String(Math.floor((s % 3600) / 60)).padStart(2, '0')}m`; +} + +// The eight points of the compass, for an azimuth an operator reads rather than +// computes. "rises at 213°" is a number; "rises SW" is a direction to look in. +const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW']; +const compass = (deg: number) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 45) % 8]; + export function SatellitePanel({ myGrid }: { myGrid: string }) { const { t } = useI18n(); const [birds, setBirds] = useState([]); @@ -84,12 +104,26 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { const [positions, setPositions] = useState([]); const [passes, setPasses] = useState([]); const [tuning, setTuning] = useState(null); + const [pass, setPass] = useState(null); const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null); const [tracking, setTracking] = useState(null); - const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); - const [favs, setFavs] = useState([]); + // A clock of its own, so every countdown on the panel ticks from one instant + // and none of them needs a round trip to Go to lose a second. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = window.setInterval(() => setNow(Date.now()), 1000); + return () => window.clearInterval(id); + }, []); + // What the operator follows. Chosen in Settings; following nothing means + // every satellite we can both find and tune, which is what somebody who has + // not chosen yet should see. + const shown = useMemo(() => { + const favs = birds.filter((b) => b.favorite); + if (favs.length > 0) return favs; + return birds.filter((b) => b.has_elements && (b.transponders?.length ?? 0) > 0); + }, [birds]); const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]); const tp = bird?.transponders?.[tpIdx] ?? null; @@ -99,22 +133,9 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { try { const list: Bird[] = (await GetSatelliteBirds()) as any; setBirds(list ?? []); - setSel((cur) => { - if (cur && (list ?? []).some((b) => b.name === cur)) return cur; - // Nothing chosen yet: the first bird we can both find and tune. - const first = (list ?? []).find((b) => b.has_elements && (b.transponders?.length ?? 0) > 0); - return first?.name ?? cur; - }); } catch (e: any) { setErr(String(e?.message ?? e)); } }, []); - const loadSettings = useCallback(async () => { - try { - const s: any = await GetSatSettings(); - setFavs(s?.favorites ?? []); - } catch { /* favourites are a convenience; the list works without them */ } - }, []); - const loadTle = useCallback(async () => { try { setTle((await GetSatelliteTLEInfo()) as any); } catch { /* shown as unknown */ } }, []); @@ -126,11 +147,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { } catch (e: any) { setErr(String(e?.message ?? e)); } }, []); - useEffect(() => { loadBirds(); loadSettings(); loadTle(); loadPasses(); }, [loadBirds, loadSettings, loadTle, loadPasses]); + useEffect(() => { loadBirds(); loadTle(); loadPasses(); }, [loadBirds, loadTle, loadPasses]); useEffect(() => EventsOn('sat:tle', () => { loadTle(); loadBirds(); loadPasses(); }), [loadTle, loadBirds, loadPasses]); useEffect(() => { if (sel) localStorage.setItem('opslog.satSelected', sel); }, [sel]); useEffect(() => { setTpIdx(0); }, [sel]); + // Keep the selection inside what is followed: an operator who narrows the list + // in Settings must not be left looking at a satellite that is no longer there. + useEffect(() => { + if (shown.length === 0) return; + if (!sel || !shown.some((b) => b.name === sel)) setSel(shown[0].name); + }, [shown, sel]); + // The map's satellites, every five seconds: a low orbit moves about a third of // a degree of longitude in that time, which is a pixel or two at this zoom. useEffect(() => { @@ -163,6 +191,22 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { return () => { live = false; window.clearInterval(id); }; }, [sel, tpIdx]); + // The pass, every twenty seconds. Predicting one steps the orbit across hours; + // the countdown itself is two timestamps and a clock, which the browser runs. + useEffect(() => { + if (!sel) { setPass(null); return; } + let live = true; + const tick = async () => { + try { + const p: PassInfo = (await GetSatelliteNextPass(sel)) as any; + if (live) setPass(p); + } catch { if (live) setPass(null); } + }; + tick(); + const id = window.setInterval(tick, 20_000); + return () => { live = false; window.clearInterval(id); }; + }, [sel]); + // Passes are cheap but not free, and they change slowly. useEffect(() => { const id = window.setInterval(loadPasses, 5 * 60_000); @@ -195,35 +239,6 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { } catch (e: any) { setErr(String(e?.message ?? e)); } }; - const refreshTle = async () => { - setBusy(true); setErr(''); - try { - setTle((await RefreshSatelliteTLE()) as any); - await loadBirds(); await loadPasses(); - } catch (e: any) { setErr(String(e?.message ?? e)); } - setBusy(false); - }; - - const pasteElements = async () => { - const text = window.prompt(t('sat.pastePrompt')); - if (!text) return; - try { - const n: number = (await AddSatelliteElements(text)) as any; - await loadBirds(); await loadPasses(); - setErr(n > 0 ? '' : t('sat.pasteNone')); - } catch (e: any) { setErr(String(e?.message ?? e)); } - }; - - const toggleFav = async (name: string) => { - const next = favs.includes(name) ? favs.filter((f) => f !== name) : [...favs, name]; - setFavs(next); - try { - const s: any = await GetSatSettings(); - await SaveSatSettings({ ...s, favorites: next }); - await loadBirds(); await loadPasses(); - } catch (e: any) { setErr(String(e?.message ?? e)); } - }; - // ── Map ────────────────────────────────────────────────────────────────── const divRef = useRef(null); @@ -312,7 +327,9 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0, }).addTo(layer); } + const wanted = new Set(shown.map((b) => b.name)); for (const p of positions) { + if (!wanted.has(p.name) && p.name !== sel) continue; const chosen = p.name === sel; const up = p.el > 0; const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af'; @@ -331,17 +348,23 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { .on('click', () => setSel(p.name)) .addTo(layer); } - }, [positions, track, home?.lat, home?.lon, myGrid, sel]); + }, [positions, track, home?.lat, home?.lon, myGrid, sel, shown]); // ── Render ─────────────────────────────────────────────────────────────── - const tleLabel = !tle ? '—' - : tle.count === 0 ? t('sat.tleNone') - : t('sat.tleAge', { n: tle.count, h: Math.round(tle.age_h) }); + // The pass, as a countdown and a bar. Both derived here from two timestamps, + // so they move every second without asking Go anything. + const aosMs = pass?.has_pass ? Date.parse(pass.aos) : 0; + const losMs = pass?.has_pass ? Date.parse(pass.los) : 0; + const inPass = !!pass?.has_pass && now >= aosMs && now < losMs; + const progress = inPass && losMs > aosMs ? (now - aosMs) / (losMs - aosMs) : 0; return (
- {/* Header: what to track, and what we know about the elements. */} + {/* Header: what to work, and the one button that touches the radio. + Everything else about satellites — which ones, where the elements come + from, the rotator — is in Settings, because none of it is something to + do while a bird is going over. */}
@@ -366,15 +390,6 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) { ))} )} - {bird && ( - - )} - {/* Tracking is the one button on this panel that touches the radio, so - it says which of the two things it is doing: holding both ends of - the pass, or only the receiver on a rig with one. */} - + {/* Elements are maintenance, so only their AGE is here — and only when + it has become a reason the panel might be wrong. */} + {tle?.stale && {t('sat.tleStale')}}