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([]); const [sel, setSel] = useState(() => localStorage.getItem('opslog.satSelected') || ''); const [tpIdx, setTpIdx] = useState(0); const [positions, setPositions] = useState([]); const [passes, setPasses] = useState([]); const [tuning, setTuning] = useState(null); const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null); const [tracking, setTracking] = useState(null); const [busy, setBusy] = useState(false); const [err, setErr] = useState(''); const [favs, setFavs] = useState([]); 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(null); const mapRef = useRef(null); const layerRef = useRef(null); const baseRef = useRef(null); const labelsRef = useRef(null); const [basemap, setBasemap] = useState(() => (localStorage.getItem('opslog.satMapBase') as BasemapKey) || 'light'); const saved = useRef(loadMapView(MAP_VIEW_SAT)); const [track, setTrack] = useState([]); 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 (
{/* Header: what to track, and what we know about the elements. */}
{(bird?.transponders?.length ?? 0) > 1 && ( )} {bird && ( )} {/* 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. */} {tracking?.on && tracking.radio === 'downlink-only' && ( {t('sat.downlinkOnly')} )}
{tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''}
{err &&
{err}
}
{/* The map. */}
{/* The readout and the pass list. */}
{bird?.name ?? '—'} {tp?.label ?? ''}
{tuning && tuning.range_km > 0 && {Math.round(tuning.range_km)} km} {tuning && {tuning.range_rate > 0 ? '+' : ''}{tuning.range_rate.toFixed(2)} km/s} {!!tp?.ctcss && CTCSS {tp.ctcss.toFixed(1)}} {tp?.inverting && {t('sat.inverting')}} {bird?.geostationary && {t('sat.geo')}}
{t('sat.nextPasses')}
{passes.length === 0 && (
{t('sat.noPasses')}
)} {passes.map((p, i) => { const mins = inMin(p.aos); const now = mins <= 0 && Date.parse(p.los) > Date.now(); return ( ); })}
); } function Readout({ label, value, colour }: { label: string; value: string; colour?: string }) { return (
{label}
{value}
); } // 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 (
{label} {fmtHz(hz)} {!!shift && ( {shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz )}
); }