feat(sat): the Satellites tab

Three questions answered at once, because on a pass there is no time to
go looking for any of them: where the bird is, when the next one comes,
and what to tune. The map draws each satellite's footprint — the honest
answer to "can I hear it", since everything inside the circle has the
satellite above its horizon — and the selected one's path over the
ground. The pass list is every favourite in time order, the one in
progress in green.

The readout shows the corrected frequency large and the nominal one
beneath it. Only one of them, and an operator cannot tell a Doppler
correction from a mistuned transponder.

The map opens on the station rather than the Atlantic, and remembers
where it was left like the others. The panel is mounted only while its
tab is visible: it asks for the tuning once a second, and there is no
reason to compute an orbit nobody is looking at.
This commit is contained in:
2026-09-07 10:56:28 +02:00
parent 1009d06a4c
commit 680bf410fe
5 changed files with 526 additions and 1 deletions
+462
View File
@@ -0,0 +1,462 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
GetSatSettings, SaveSatSettings,
} from '../../wailsjs/go/main/App';
import { EventsOn } from '../../wailsjs/runtime/runtime';
import { Button } from '@/components/ui/button';
import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { loadMapView, saveMapView } from '@/lib/mapView';
import { cn } from '@/lib/utils';
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.
type Bird = {
name: string; norad: number; geostationary: boolean; favorite: boolean;
has_elements: boolean; element_name: string; epoch_age_h: number;
transponders?: {
label: string; mode: string; down_lo: number; down_hi: number;
up_lo: number; up_hi: number; inverting: boolean; ctcss: number; linear: boolean;
}[];
};
type Position = {
name: string; lat: number; lon: number; alt_km: number; footprint_km: number;
az: number; el: number; range_km: number; range_rate: number;
};
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 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;
};
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 ');
};
const fmtDeg = (d: number) => `${d.toFixed(1)}°`;
const hhmm = (iso: string) => {
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? '—' : d.toISOString().slice(11, 16);
};
const inMin = (iso: string) => Math.round((Date.parse(iso) - Date.now()) / 60000);
export function SatellitePanel({ myGrid }: { myGrid: string }) {
const { t } = useI18n();
const [birds, setBirds] = useState<Bird[]>([]);
const [sel, setSel] = useState<string>(() => localStorage.getItem('opslog.satSelected') || '');
const [tpIdx, setTpIdx] = useState(0);
const [positions, setPositions] = useState<Position[]>([]);
const [passes, setPasses] = useState<Pass[]>([]);
const [tuning, setTuning] = useState<Tuning | null>(null);
const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState('');
const [favs, setFavs] = useState<string[]>([]);
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
const tp = bird?.transponders?.[tpIdx] ?? null;
// ── Data ─────────────────────────────────────────────────────────────────
const loadBirds = useCallback(async () => {
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 */ }
}, []);
const loadPasses = useCallback(async () => {
try {
setPasses(((await GetSatellitePasses([], 0)) as any) ?? []);
setErr('');
} catch (e: any) { setErr(String(e?.message ?? e)); }
}, []);
useEffect(() => { loadBirds(); loadSettings(); loadTle(); loadPasses(); }, [loadBirds, loadSettings, 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]);
// 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(() => {
let live = true;
const tick = async () => {
try {
const p: Position[] = ((await GetSatellitePositions([])) as any) ?? [];
if (live) setPositions(p);
} catch { /* a missing locator is already reported by the passes call */ }
};
tick();
const id = window.setInterval(tick, 5000);
return () => { live = false; window.clearInterval(id); };
}, []);
// The readout, every second: this is the number an operator types into a
// radio, and a Doppler correction on 70 cm moves by a few tens of hertz a
// second at the middle of a pass.
useEffect(() => {
if (!sel) { setTuning(null); return; }
let live = true;
const tick = async () => {
try {
const tn: Tuning = (await GetSatelliteTuning(sel, tpIdx, 0)) as any;
if (live) setTuning(tn);
} catch { if (live) setTuning(null); }
};
tick();
const id = window.setInterval(tick, 1000);
return () => { live = false; window.clearInterval(id); };
}, [sel, tpIdx]);
// Passes are cheap but not free, and they change slowly.
useEffect(() => {
const id = window.setInterval(loadPasses, 5 * 60_000);
return () => window.clearInterval(id);
}, [loadPasses]);
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);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() =>
(localStorage.getItem('opslog.satMapBase') as BasemapKey) || 'light');
const saved = useRef(loadMapView(MAP_VIEW_SAT));
const [track, setTrack] = useState<Position[]>([]);
const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);
useEffect(() => {
if (!divRef.current || mapRef.current) return;
const m = L.map(divRef.current, {
zoomControl: true, attributionControl: true,
worldCopyJump: false, preferCanvas: true,
// Opened on the station, not on the Atlantic: the passes that matter are
// the ones over the operator's own head.
center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0],
zoom: saved.current ? saved.current.zoom : 3,
minZoom: 2,
});
m.on('moveend', () => {
const c = m.getCenter();
saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom());
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
const ro = new ResizeObserver(() => m.invalidateSize({ animate: false }));
ro.observe(divRef.current);
const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100);
return () => {
window.clearTimeout(settle);
ro.disconnect();
m.remove();
mapRef.current = null;
layerRef.current = null;
};
}, [home?.lat, home?.lon]);
useEffect(() => {
const m = mapRef.current;
if (!m) return;
baseRef.current?.remove(); labelsRef.current?.remove();
const bm = BASEMAPS[basemap];
const opts: L.TileLayerOptions = {
maxNativeZoom: bm.maxNativeZoom, noWrap: true,
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
localStorage.setItem('opslog.satMapBase', basemap);
}, [basemap]);
// The selected bird's path over the ground, redrawn when the selection
// changes and every couple of minutes as it walks off the front of it.
useEffect(() => {
if (!sel) { setTrack([]); return; }
let live = true;
const load = async () => {
try {
const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? [];
if (live) setTrack(pts);
} catch { if (live) setTrack([]); }
};
load();
const id = window.setInterval(load, 120_000);
return () => { live = false; window.clearInterval(id); };
}, [sel]);
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
if (home) {
L.circleMarker([home.lat, home.lon], {
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
}).bindTooltip(myGrid, { direction: 'top' }).addTo(layer);
}
if (track.length > 1) {
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
L.polyline(pts as L.LatLngExpression[][], {
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0,
}).addTo(layer);
}
for (const p of positions) {
const chosen = p.name === sel;
const up = p.el > 0;
const colour = chosen ? '#22c55e' : up ? '#f59e0b' : '#9ca3af';
// The footprint is the honest answer to "can I hear it": everything inside
// the circle has the satellite above its horizon.
L.circle([p.lat, p.lon], {
radius: p.footprint_km * 1000,
color: colour, weight: chosen ? 1.2 : 0.8, opacity: chosen ? 0.7 : 0.35,
fillColor: colour, fillOpacity: chosen ? 0.1 : 0.05,
}).addTo(layer);
L.circleMarker([p.lat, p.lon], {
radius: chosen ? 6 : 4, color: '#fff', weight: 1,
fillColor: colour, fillOpacity: 1,
})
.bindTooltip(`${p.name} · ${fmtDeg(p.el)} · ${Math.round(p.alt_km)} km`, { direction: 'top' })
.on('click', () => setSel(p.name))
.addTo(layer);
}
}, [positions, track, home?.lat, home?.lon, myGrid, sel]);
// ── Render ───────────────────────────────────────────────────────────────
const tleLabel = !tle ? '—'
: tle.count === 0 ? t('sat.tleNone')
: t('sat.tleAge', { n: tle.count, h: Math.round(tle.age_h) });
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. */}
<div className="flex items-center gap-2 flex-wrap px-1 shrink-0">
<SatIcon className="size-4 text-muted-foreground" />
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs min-w-[12rem]"
value={sel}
onChange={(e) => setSel(e.target.value)}
>
{birds.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')}`}
</option>
))}
</select>
{(bird?.transponders?.length ?? 0) > 1 && (
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
value={tpIdx}
onChange={(e) => setTpIdx(Number(e.target.value))}
>
{bird!.transponders!.map((x, i) => (
<option key={i} value={i}>{x.label}</option>
))}
</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>
)}
<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>
<select
className="h-7 rounded-md border border-border bg-background px-2 text-xs"
value={basemap}
onChange={(e) => setBasemap(e.target.value as BasemapKey)}
>
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select>
</div>
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
<div className="flex gap-1 flex-1 min-h-0">
{/* The map. */}
<div className="flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
<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="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) : '—'} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning ? upColour(tuning.visible) : undefined} />
</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>
<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>
</div>
<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')}
</div>
<div className="flex-1 min-h-0 overflow-auto">
{passes.length === 0 && (
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
)}
{passes.map((p, i) => {
const mins = inMin(p.aos);
const now = mins <= 0 && Date.parse(p.los) > Date.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',
)}
onClick={() => setSel(p.name)}
>
<span className="w-20 truncate font-medium">{p.name}</span>
<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>
</button>
);
})}
</div>
</div>
</div>
</div>
</div>
);
}
function Readout({ label, value, colour }: { label: string; value: string; colour?: 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>
</div>
);
}
// The corrected frequency large, the nominal one small underneath. 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 }) {
const shift = hz && nominal ? hz - nominal : 0;
return (
<div className="flex items-baseline gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-base font-semibold tabular-nums">{fmtHz(hz)}</span>
{!!shift && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{shift > 0 ? '+' : ''}{Math.abs(Math.round(shift))} Hz
</span>
)}
</div>
);
}