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:
@@ -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>}
|
||||
</div>
|
||||
{/* 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}>
|
||||
{value}
|
||||
<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 }) {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 station’s',
|
||||
@@ -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 Celestrak’s 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 tonight’s 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 n’a qu’un 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 l’horizon',
|
||||
'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 n’y 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 l’antenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans l’onglet 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 à l’intérieur de votre ouverture de faisceau.',
|
||||
'satset.rotPark': 'Ranger au nord, élévation zéro, à l’arrê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 : l’onglet 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 qu’aucun flux ne diffuse encore. Conservés dans leur propre fichier : une mise à jour ne les efface jamais.',
|
||||
'satset.follow': 'Satellites à suivre',
|
||||
'satset.followHint': 'L’onglet Satellites et la liste des passages n’affichent que ceux-ci. N’en 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 };
|
||||
|
||||
Vendored
+2
@@ -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>>;
|
||||
|
||||
@@ -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']();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user