Files
OpsLog/frontend/src/components/SatellitePanel.tsx
T
rouggy 465481f8f1 feat(sat): Doppler tracking on the radio
The hard part of satellite tuning is not the arithmetic, it is deciding
who owns the dial. A tracker that forces both frequencies fights the
operator every time they turn the knob to follow a station across a
linear transponder; one that never touches the receiver leaves them
chasing a signal that slides nine kilohertz across a 70 cm pass.

So the operator owns the receiver and the tracker follows them. Every
second it asks the radio where the receiver actually is. Where it put it,
nothing has changed. Further than a dial-turn's tolerance, and the
operator has chosen a station: what they landed on is converted back into
a nominal frequency, and the transmitter is derived from that. Which is
the division of labour on a linear bird — the operator listens, the radio
does the sums.

Three ways to reach the radio, because a satellite pair is a shape of
operating rather than a manufacturer's feature. An IC-9700 or IC-9100 is
asked for its OWN satellite mode: it pairs main and sub, gives full
duplex, and keeps the dials linked the way its designers meant, which is
always better than an imitation built out of split. A Flex gets two
slices, A the downlink and B the uplink, created when missing, because
"slice B does not exist" is not something to make an operator fix at the
start of a ten-minute pass. Everything else gets the downlink, and is
told so — half the job announced beats half the job hidden.

What goes in the log is the NOMINAL pair. Two stations working each other
through a transponder read different numbers off their dials at the same
instant; the only figure they can both agree on is the transponder's own.
FREQ is the uplink and FREQ_RX the downlink — the one place a satellite
QSO differs from every other kind, and the reason FREQ alone cannot
describe one.
2026-09-07 11:27:06 +02:00

517 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
GetSatSettings, SaveSatSettings,
StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking,
} 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;
};
type Track = {
on: boolean; name: string; transponder: string; mode: string;
nominal_down: number; nominal_up: number; down_hz: number; up_hz: number;
az: number; el: number; visible: boolean;
radio: string; // "sat" | "downlink-only" | ""
error: string;
};
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 [tracking, setTracking] = useState<Track | 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]);
// The tracker's own state, pushed as it moves. Polled as well, at a lazy
// rate, so a panel opened while tracking is already running is not blank
// until the next tick.
useEffect(() => {
const read = async () => {
try { setTracking((await GetSatelliteTracking()) as any); } catch { /* not tracking */ }
};
read();
const off = EventsOn('sat:track', (s: any) => setTracking(s ?? null));
const id = window.setInterval(read, 10_000);
return () => { off(); window.clearInterval(id); };
}, []);
const toggleTracking = async () => {
setErr('');
try {
if (tracking?.on) {
await StopSatelliteTracking();
setTracking(null);
} else {
await StartSatelliteTracking(sel, tpIdx);
setTracking((await GetSatelliteTracking()) as any);
}
} 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);
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>
)}
{/* 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'}
className={cn('h-7 px-2 gap-1.5', tracking?.on && 'bg-success text-background hover:bg-success/90')}
onClick={toggleTracking}
disabled={!bird?.transponders?.length}
title={tracking?.on
? (tracking.radio === 'sat' ? t('sat.trackingFull') : t('sat.trackingDown'))
: t('sat.trackTip')}
>
<Radio className="size-3.5" />
{tracking?.on ? t('sat.tracking') : t('sat.track')}
</Button>
{tracking?.on && tracking.radio === 'downlink-only' && (
<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>
<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>
);
}