feat(sat): set it up in Settings, work the pass in the tab

Two things belong in different places, and they were in one.

Settings → Satellites now holds the setup: which satellites to follow —
the same two-column shape as the awards, for the same reason, since a
feed carries two hundred birds and an operator works six — and the
orbital elements, their age, the fetch, and pasting your own. Following
none still means every satellite with both elements and a frequency plan,
so somebody who has not chosen yet is not handed an empty tab.

The panel keeps only what a pass needs. A countdown to AOS, or to LOS
once it is up, because that is the number that decides whether you sit
down; a bar for where in the pass you are, since mid-pass the useful
question is not the clock but whether you are past the peak; rise, peak
and set with compass directions, because "rises SW" is a direction to
look in and 213° is arithmetic. Distance, altitude and footprint. And
approaching or receding, which is the sign of the whole Doppler
correction and the only thing that explains why the frequencies are
moving the way they are.

The countdowns run in the browser from two timestamps. Predicting a pass
steps the orbit across a day thirty seconds at a time, which is not
something to do once a second for a clock the page can keep itself.
This commit is contained in:
2026-09-07 15:34:26 +02:00
parent 7f03b046ab
commit 3ed48336af
8 changed files with 557 additions and 122 deletions
+76
View File
@@ -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
}
+6 -2
View File
@@ -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é."
]
},
{
+197 -113
View File
@@ -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<Bird[]>([]);
@@ -84,12 +104,26 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [positions, setPositions] = useState<Position[]>([]);
const [passes, setPasses] = useState<Pass[]>([]);
const [tuning, setTuning] = useState<Tuning | null>(null);
const [pass, setPass] = useState<PassInfo | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
const [tracking, setTracking] = useState<Track | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [favs, setFavs] = useState<string[]>([]);
// 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<HTMLDivElement>(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 (
<div className="flex flex-col h-full min-h-0 gap-1 p-1">
{/* 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. */}
<div className="flex items-center gap-2 flex-wrap px-1 shrink-0">
<SatIcon className="size-4 text-muted-foreground" />
<select
@@ -349,9 +372,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
value={sel}
onChange={(e) => setSel(e.target.value)}
>
{birds.map((b) => (
{shown.length === 0 && <option value="">{t('sat.noneFollowed')}</option>}
{shown.map((b) => (
<option key={b.name} value={b.name} disabled={!b.has_elements && !b.geostationary}>
{b.favorite ? '★ ' : ''}{b.name}{b.has_elements ? '' : `${t('sat.noElements')}`}
{b.name}{b.has_elements || b.geostationary ? '' : `${t('sat.noElements')}`}
</option>
))}
</select>
@@ -366,15 +390,6 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
))}
</select>
)}
{bird && (
<Button variant="ghost" size="sm" className="h-7 px-2" onClick={() => toggleFav(bird.name)}
title={t('sat.favTip')}>
<Star className={cn('size-3.5', favs.includes(bird.name) && 'fill-warning text-warning')} />
</Button>
)}
{/* 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. */}
<Button
size="sm"
variant={tracking?.on ? 'default' : 'outline'}
@@ -392,17 +407,9 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<span className="text-[11px] text-warning">{t('sat.downlinkOnly')}</span>
)}
<div className="flex-1" />
<span className={cn('text-[11px] tabular-nums', tle?.stale ? 'text-warning' : 'text-muted-foreground')}>
{tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''}
</span>
<Button variant="outline" size="sm" className="h-7 px-2 gap-1.5" onClick={refreshTle} disabled={busy}>
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} />
{t('sat.refreshTle')}
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 gap-1.5" onClick={pasteElements} title={t('sat.pasteTip')}>
<ClipboardPaste className="size-3.5" />
{t('sat.paste')}
</Button>
{/* Elements are maintenance, so only their AGE is here — and only when
it has become a reason the panel might be wrong. */}
{tle?.stale && <span className="text-[11px] text-warning">{t('sat.tleStale')}</span>}
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
value={basemap}
@@ -424,32 +431,81 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<div ref={divRef} className="h-full w-full" />
</div>
{/* The readout and the pass list. */}
<div className="w-[19rem] shrink-0 flex flex-col gap-1 min-h-0">
<div className="w-[21rem] shrink-0 flex flex-col gap-1 min-h-0">
{/* The pass. The first thing an operator looks at and the reason they
sit down: how long have I got, and how high does it get. */}
<div className={cn('rounded-lg border bg-card p-2',
inPass ? 'border-success/60' : 'border-border')}>
<div className="flex items-baseline justify-between gap-2">
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
</div>
{bird?.geostationary ? (
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
) : !pass?.has_pass ? (
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
) : (
<>
<div className="mt-1 flex items-baseline gap-2">
<span className={cn('text-[10px] uppercase tracking-wide',
inPass ? 'text-success' : 'text-muted-foreground')}>
{inPass ? t('sat.los') : t('sat.aos')}
</span>
<span className={cn('text-xl font-semibold tabular-nums leading-none',
inPass && 'text-success')}>
{fmtCountdown((inPass ? losMs : aosMs) - now)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
{hhmmss(inPass ? pass.los : pass.aos)}Z
</span>
</div>
{/* Where in the pass we are. A bar because the useful question
mid-pass is not the clock but "am I past the peak". */}
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
style={{ width: `${Math.round(progress * 100)}%` }} />
</div>
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
strong={pass.max_el >= 30} />
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
</div>
</>
)}
</div>
{/* Where it is, right now. */}
<div className="rounded-lg border border-border bg-card p-2">
<div className="flex items-baseline justify-between">
<span className="font-medium text-sm">{bird?.name ?? '—'}</span>
<span className="text-[11px] text-muted-foreground">{tp?.label ?? ''}</span>
</div>
<div className="mt-2 grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'} />
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning ? upColour(tuning.visible) : undefined} />
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 space-y-1">
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
<div className="mt-2 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground tabular-nums">
{tuning && tuning.range_km > 0 && <span>{Math.round(tuning.range_km)} km</span>}
{tuning && <span>{tuning.range_rate > 0 ? '+' : ''}{tuning.range_rate.toFixed(2)} km/s</span>}
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
{tp?.inverting && <span>{t('sat.inverting')}</span>}
{bird?.geostationary && <span>{t('sat.geo')}</span>}
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
@@ -464,6 +520,22 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)}
</div>
{/* What to tune. */}
<div className="rounded-lg border border-border bg-card p-2">
<div className="space-y-1">
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
</div>
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
{!!tp?.mode && <span>{tp.mode}</span>}
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
{tp?.inverting && <span>{t('sat.inverting')}</span>}
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
{bird?.geostationary && <span>{t('sat.geo')}</span>}
</div>
</div>
{/* What is coming. */}
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
{t('sat.nextPasses')}
@@ -474,14 +546,14 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)}
{passes.map((p, i) => {
const mins = inMin(p.aos);
const now = mins <= 0 && Date.parse(p.los) > Date.now();
const running = mins <= 0 && Date.parse(p.los) > now;
return (
<button
key={`${p.name}-${p.aos}-${i}`}
className={cn(
'w-full text-left px-2 py-1 flex items-center gap-2 text-[11px] tabular-nums hover:bg-accent/50',
p.name === sel && 'bg-accent/30',
now && 'text-success font-medium',
running && 'text-success font-medium',
)}
onClick={() => setSel(p.name)}
>
@@ -489,8 +561,8 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<span>{hhmm(p.aos)}</span>
<span className="text-muted-foreground"> {hhmm(p.los)}</span>
<span className="ml-auto">{Math.round(p.max_el)}°</span>
<span className="w-14 text-right text-muted-foreground">
{now ? t('sat.now') : mins < 60 ? `${mins}m` : `${Math.round(mins / 60)}h`}
<span className="w-16 text-right text-muted-foreground">
{running ? t('sat.now') : fmtCountdown(Date.parse(p.aos) - now)}
</span>
</button>
);
@@ -503,18 +575,30 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
);
}
function Readout({ label, value, colour }: { label: string; value: string; colour?: string }) {
function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) {
return (
<div className="rounded-md bg-muted/40 px-2 py-1">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground">{label}</div>
<div className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
<div className="flex items-baseline gap-1.5">
<span className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
{value}
</span>
{!!sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
</div>
</div>
);
}
// The corrected frequency large, the nominal one small underneath. Showing only
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
return (
<div className="min-w-0">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
</div>
);
}
// The corrected frequency large, the nominal one small beside it. Showing only
// one of them leaves an operator unable to tell a Doppler correction from a
// mistuned transponder.
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
+173 -5
View File
@@ -20,6 +20,7 @@ import {
GetTunerGeniusSettings, SaveTunerGeniusSettings,
GetPSUSettings, SavePSUSettings,
GetSatSettings, SaveSatSettings, TestSatelliteRotator,
GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements, GetSatelliteBirds,
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
@@ -1323,6 +1324,168 @@ function AwardsSelectionPanel({ profile }: { profile?: { name?: string; callsign
);
}
// SatelliteElementsBlock is where the orbital elements are kept up to date.
//
// In Settings rather than in the tab because it is maintenance, not operating:
// during a pass an operator wants the frequencies and the countdown, not a
// download button. Module-scope so it may hold its own hooks (see PanelHost).
function SatelliteElementsBlock({ autoTle, onAutoTle }: { autoTle: boolean; onAutoTle: (v: boolean) => void }) {
const { t } = useI18n();
const [info, setInfo] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState('');
const [paste, setPaste] = useState('');
const [showPaste, setShowPaste] = useState(false);
const read = async () => { try { setInfo(await GetSatelliteTLEInfo() as any); } catch { /* shown as unknown */ } };
useEffect(() => { read(); }, []);
const refresh = async () => {
setBusy(true); setMsg('');
try {
const i: any = await RefreshSatelliteTLE();
setInfo(i);
setMsg(t('satset.tleFetched', { n: i?.count ?? 0 }));
} catch (e: any) { setMsg(String(e?.message ?? e)); }
setBusy(false);
};
const addPasted = async () => {
setMsg('');
try {
const n: number = (await AddSatelliteElements(paste)) as any;
setPaste(''); setShowPaste(false);
await read();
setMsg(t('satset.tleAdded', { n }));
} catch (e: any) { setMsg(String(e?.message ?? e)); }
};
const age = !info ? '—'
: info.count === 0 ? t('satset.tleNone')
: t('satset.tleAge', { n: info.count, h: Math.round(info.age_h) });
return (
<div className="space-y-3">
<h4 className="text-sm font-semibold text-foreground">{t('satset.elements')}</h4>
<div className="flex items-center gap-3 flex-wrap">
<span className={cn('text-sm tabular-nums', info?.stale ? 'text-warning' : 'text-muted-foreground')}>{age}</span>
{!!info?.custom && <span className="text-xs text-muted-foreground">{t('satset.tleCustom', { n: info.custom })}</span>}
<Button size="sm" variant="outline" onClick={refresh} disabled={busy}>
{busy ? t('satset.tleFetching') : t('satset.tleFetch')}
</Button>
<Button size="sm" variant="ghost" onClick={() => setShowPaste((v) => !v)}>{t('satset.tlePaste')}</Button>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={autoTle} onCheckedChange={(c) => onAutoTle(!!c)} />
{t('satset.autoTle')}
</label>
<p className="text-xs text-muted-foreground">{t('satset.tleHint')}</p>
{showPaste && (
<div className="space-y-2">
<Textarea rows={5} className="font-mono text-xs" placeholder={t('satset.tlePastePlaceholder')}
value={paste} onChange={(e) => setPaste(e.target.value)} />
<div className="flex items-center gap-2">
<Button size="sm" onClick={addPasted} disabled={!paste.trim()}>{t('satset.tlePasteAdd')}</Button>
<span className="text-xs text-muted-foreground">{t('satset.tlePasteHint')}</span>
</div>
</div>
)}
{msg && <div className="text-xs text-muted-foreground">{msg}</div>}
</div>
);
}
// SatelliteFollowList chooses which satellites the tab shows and the tracker
// offers — the same two-column shape as the awards, for the same reason: a feed
// carries a couple of hundred birds and an operator works six.
//
// Following none means following every satellite that has both elements and a
// frequency plan, which is the sensible thing for somebody who has not chosen
// yet and the reason the list does not start out empty-handed.
function SatelliteFollowList({ followed, onChange }: { followed: string[]; onChange: (next: string[]) => void }) {
const { t } = useI18n();
const [all, setAll] = useState<any[]>([]);
const [q, setQ] = useState('');
const [withPlanOnly, setWithPlanOnly] = useState(true);
useEffect(() => {
(async () => {
try { setAll(((await GetSatelliteBirds()) ?? []) as any[]); } catch { /* an empty list says it itself */ }
})();
}, []);
const followedSet = new Set(followed.map((s) => s.toUpperCase()));
const byName = new Map(all.map((b) => [b.name as string, b] as const));
const needle = q.trim().toLowerCase();
const available = all.filter((b) => !followedSet.has(String(b.name).toUpperCase())
&& (!withPlanOnly || (b.transponders?.length ?? 0) > 0)
&& (needle === '' || String(b.name).toLowerCase().includes(needle)));
const chosen = followed.map((n) => byName.get(n) ?? { name: n, has_elements: false, transponders: [] });
const label = (b: any) => {
const bits: string[] = [];
if ((b.transponders?.length ?? 0) > 0) bits.push(b.transponders.map((x: any) => x.mode).filter((m: string, i: number, a: string[]) => a.indexOf(m) === i).join('/'));
if (b.geostationary) bits.push(t('sat.geo'));
if (!b.has_elements) bits.push(t('sat.noElements'));
return bits.join(' · ');
};
return (
<div className="space-y-2">
<h4 className="text-sm font-semibold text-foreground">{t('satset.follow')}</h4>
<p className="text-xs text-muted-foreground">{t('satset.followHint')}</p>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-lg border border-border bg-card/40 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
<span className="text-sm font-medium">{t('satset.available')} <span className="text-muted-foreground">({available.length})</span></span>
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
disabled={available.length === 0}
onClick={() => onChange([...followed, ...available.map((b) => b.name as string)])}>
{t('awards.addAll')}
</button>
</div>
<div className="p-2 border-b border-border/60 space-y-2">
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder={t('satset.search')} className="h-8" />
<label className="flex items-center gap-2 text-xs cursor-pointer text-muted-foreground">
<Checkbox checked={withPlanOnly} onCheckedChange={(c) => setWithPlanOnly(!!c)} />
{t('satset.withPlanOnly')}
</label>
</div>
<div className="max-h-[300px] overflow-y-auto p-1.5 space-y-0.5">
{available.map((b) => (
<button key={b.name} type="button" onClick={() => onChange([...followed, b.name])}
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
<span className="font-mono text-xs shrink-0">{b.name}</span>
<span className="text-[11px] text-muted-foreground truncate flex-1">{label(b)}</span>
<span className="text-primary opacity-0 group-hover:opacity-100"></span>
</button>
))}
{available.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('satset.allFollowed')}</div>}
</div>
</div>
<div className="rounded-lg border border-primary/40 bg-primary/5 flex flex-col">
<div className="flex items-center justify-between px-3 py-2 border-b border-border/60">
<span className="text-sm font-medium">{t('satset.followed')} <span className="text-muted-foreground">({followed.length})</span></span>
<button type="button" className="text-xs text-primary hover:underline disabled:opacity-40"
disabled={followed.length === 0} onClick={() => onChange([])}>{t('awards.clear')}</button>
</div>
<div className="max-h-[352px] overflow-y-auto p-1.5 space-y-0.5">
{chosen.map((b: any) => (
<button key={b.name} type="button" onClick={() => onChange(followed.filter((n) => n !== b.name))}
className="group w-full flex items-center gap-2 rounded-md px-2 py-1 text-left hover:bg-primary/10">
<span className="text-muted-foreground opacity-0 group-hover:opacity-100"></span>
<span className="font-mono text-xs shrink-0">{b.name}</span>
<span className="text-[11px] text-muted-foreground truncate flex-1">{label(b)}</span>
</button>
))}
{followed.length === 0 && <div className="p-2 text-xs text-muted-foreground">{t('satset.noneFollowed')}</div>}
</div>
</div>
</div>
</div>
);
}
function ComingSoon({ id, icon: Icon }: { id: SectionId; icon?: any }) {
const label = SECTION_LABELS[id] ?? id;
const IconCmp = Icon ?? Construction;
@@ -4390,6 +4553,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
return (
<>
<SectionHeader title={t('sec.satellite')} hint={t('satset.hint')} />
<div className="space-y-5 max-w-xl mb-5">
<SatelliteElementsBlock autoTle={!!satCfg.auto_tle} onAutoTle={(v) => set('auto_tle', v)} />
</div>
<div className="max-w-3xl mb-5">
<SatelliteFollowList
followed={satCfg.favorites ?? []}
onChange={(next) => set('favorites', next)}
/>
</div>
<div className="space-y-5 max-w-xl">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
@@ -4410,16 +4582,12 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div>
<p className="text-xs text-muted-foreground">{t('satset.gridHint')}</p>
<div className="grid grid-cols-2 gap-3">
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label>{t('satset.windowH')}</Label>
<Input className="font-mono" value={String(satCfg.window_h ?? 24)}
onChange={(e) => set('window_h', num(e.target.value))} />
</div>
<label className="flex items-end gap-2 text-sm cursor-pointer pb-2">
<Checkbox checked={!!satCfg.auto_tle} onCheckedChange={(c) => set('auto_tle', !!c)} />
{t('satset.autoTle')}
</label>
</div>
<div className="border-t border-border/60 pt-4 space-y-3">
+36
View File
@@ -588,6 +588,13 @@ const en: Dict = {
'sat.trackingDown': 'Tracking the downlink only — this radio has one receiver.',
'sat.downlinkOnly': 'downlink only', 'sat.nominal': 'nominal',
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
'sat.approaching': 'approaching', 'sat.receding': 'receding', 'sat.below': 'below the horizon',
'sat.noPassSoon': 'No pass in the next day — check the elements, or your minimum elevation.',
'sat.geoHint': 'Geostationary: always there, no Doppler to correct. Point once and leave it.',
'sat.noneFollowed': 'no satellite followed — choose some in Settings',
'sat.tleStale': 'elements are old — refresh them in Settings',
'sec.satellite': 'Satellites',
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'your stations',
@@ -603,6 +610,17 @@ const en: Dict = {
'satset.rotRangeHint': 'A 450° rotator follows a pass straight through north instead of unwinding, so it is used that way when it can be. “Start above” leaves the mast alone until the satellite is worth pointing at; “move by” is the smallest change worth a command — keep it inside your beamwidth.',
'satset.rotPark': 'Park at north, elevation zero, when tracking stops',
'satset.rotTest': 'Test the rotator', 'satset.rotTesting': 'asking the controller…',
'satset.elements': 'Orbital elements',
'satset.tleAge': '{n} satellites, {h} h old', 'satset.tleNone': 'no elements yet', 'satset.tleCustom': '{n} of your own',
'satset.tleFetch': 'Fetch now', 'satset.tleFetching': 'fetching…', 'satset.tleFetched': '{n} satellites.',
'satset.tleHint': 'From Celestraks amateur list, with a mirror behind it. They are kept on disk, so the Satellites tab is full the moment it opens even with no internet — a set a few days old still predicts tonights passes perfectly well.',
'satset.tlePaste': 'Paste elements…', 'satset.tlePasteAdd': 'Add these', 'satset.tleAdded': '{n} satellites added.',
'satset.tlePastePlaceholder': 'ISS (ZARYA)\n1 25544U 98067A …\n2 25544 51.6…',
'satset.tlePasteHint': 'For a satellite no feed carries yet. Kept in their own file, so a refresh never wipes them.',
'satset.follow': 'Satellites to follow',
'satset.followHint': 'The Satellites tab and the pass list show these, and nothing else. Follow none and every satellite that has both elements and a frequency plan is shown.',
'satset.available': 'Available', 'satset.followed': 'Followed', 'satset.noneFollowed': 'None — every usable satellite is shown.',
'satset.allFollowed': 'All of them are followed.', 'satset.search': 'Search…', 'satset.withPlanOnly': 'Only those with a frequency plan',
};
const fr: Dict = {
@@ -1154,6 +1172,13 @@ const fr: Dict = {
'sat.trackingDown': 'Seule la descente est suivie — cette radio na quun récepteur.',
'sat.downlinkOnly': 'descente seule', 'sat.nominal': 'nominal',
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous lhorizon',
'sat.noPassSoon': 'Aucun passage dans les 24 h — vérifiez les éléments, ou votre élévation minimale.',
'sat.geoHint': 'Géostationnaire : toujours là, aucun Doppler à corriger. On pointe une fois et on ny touche plus.',
'sat.noneFollowed': 'aucun satellite suivi — choisissez-en dans les Réglages',
'sat.tleStale': 'éléments anciens — actualisez-les dans les Réglages',
'sec.satellite': 'Satellites',
'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.',
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'celui de la station',
@@ -1169,6 +1194,17 @@ const fr: Dict = {
'satset.rotRangeHint': 'Un rotor 450° suit un passage à travers le nord sans se dérouler ; il est donc utilisé ainsi quand il le peut. « Démarrer au-dessus de » laisse le pylône tranquille tant que le satellite ne mérite pas d’être pointé ; « déplacer par » est le plus petit écart qui vaut une commande — gardez-le à lintérieur de votre ouverture de faisceau.',
'satset.rotPark': 'Ranger au nord, élévation zéro, à larrêt du suivi',
'satset.rotTest': 'Tester le rotor', 'satset.rotTesting': 'interrogation du contrôleur…',
'satset.elements': 'Éléments orbitaux',
'satset.tleAge': '{n} satellites, {h} h', 'satset.tleNone': 'aucun élément', 'satset.tleCustom': 'dont {n} à vous',
'satset.tleFetch': 'Récupérer', 'satset.tleFetching': 'récupération…', 'satset.tleFetched': '{n} satellites.',
'satset.tleHint': 'Depuis la liste amateur de Celestrak, avec un miroir derrière. Ils sont conservés sur disque : longlet Satellites est rempli dès son ouverture, même sans internet — un jeu vieux de quelques jours prédit très bien les passages de ce soir.',
'satset.tlePaste': 'Coller des éléments…', 'satset.tlePasteAdd': 'Ajouter', 'satset.tleAdded': '{n} satellites ajoutés.',
'satset.tlePastePlaceholder': 'ISS (ZARYA)\n1 25544U 98067A …\n2 25544 51.6…',
'satset.tlePasteHint': 'Pour un satellite quaucun flux ne diffuse encore. Conservés dans leur propre fichier : une mise à jour ne les efface jamais.',
'satset.follow': 'Satellites à suivre',
'satset.followHint': 'Longlet Satellites et la liste des passages naffichent que ceux-ci. Nen suivez aucun et tous les satellites ayant à la fois des éléments et un plan de fréquences sont affichés.',
'satset.available': 'Disponibles', 'satset.followed': 'Suivis', 'satset.noneFollowed': 'Aucun — tous les satellites utilisables sont affichés.',
'satset.allFollowed': 'Tous sont suivis.', 'satset.search': 'Rechercher…', 'satset.withPlanOnly': 'Seulement ceux avec un plan de fréquences',
};
const dicts: Record<Lang, Dict> = { en, fr };
+2
View File
@@ -597,6 +597,8 @@ export function GetSatelliteBirds():Promise<Array<main.SatBird>>;
export function GetSatelliteGroundTrack(arg1:string,arg2:number):Promise<Array<sat.Position>>;
export function GetSatelliteNextPass(arg1:string):Promise<main.SatPassInfo>;
export function GetSatelliteObserver():Promise<Record<string, any>>;
export function GetSatellitePasses(arg1:Array<string>,arg2:number):Promise<Array<sat.Pass>>;
+4
View File
@@ -1126,6 +1126,10 @@ export function GetSatelliteGroundTrack(arg1, arg2) {
return window['go']['main']['App']['GetSatelliteGroundTrack'](arg1, arg2);
}
export function GetSatelliteNextPass(arg1) {
return window['go']['main']['App']['GetSatelliteNextPass'](arg1);
}
export function GetSatelliteObserver() {
return window['go']['main']['App']['GetSatelliteObserver']();
}
+61
View File
@@ -4075,6 +4075,59 @@ export namespace main {
return a;
}
}
export class SatPassInfo {
name: string;
has_pass: boolean;
in_pass: boolean;
// Go type: time
aos: any;
// Go type: time
los: any;
aos_az: number;
los_az: number;
max_el: number;
max_el_az: number;
// Go type: time
max_el_at: any;
duration_s: number;
static createFrom(source: any = {}) {
return new SatPassInfo(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.name = source["name"];
this.has_pass = source["has_pass"];
this.in_pass = source["in_pass"];
this.aos = this.convertValues(source["aos"], null);
this.los = this.convertValues(source["los"], null);
this.aos_az = source["aos_az"];
this.los_az = source["los_az"];
this.max_el = source["max_el"];
this.max_el_az = source["max_el_az"];
this.max_el_at = this.convertValues(source["max_el_at"], null);
this.duration_s = source["duration_s"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class SatSettings {
favorites: string[];
min_el: number;
@@ -4218,6 +4271,10 @@ export namespace main {
visible: boolean;
// Go type: time
at: any;
lat: number;
lon: number;
alt_km: number;
footprint_km: number;
static createFrom(source: any = {}) {
return new SatTuning(source);
@@ -4240,6 +4297,10 @@ export namespace main {
this.range_rate = source["range_rate"];
this.visible = source["visible"];
this.at = this.convertValues(source["at"], null);
this.lat = source["lat"];
this.lon = source["lon"];
this.alt_km = source["alt_km"];
this.footprint_km = source["footprint_km"];
}
convertValues(a: any, classs: any, asMap: boolean = false): any {