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 }) {
|
||||
|
||||
Reference in New Issue
Block a user