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:
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user