import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown } from 'lucide-react'; import { GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, 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 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; 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 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; 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; rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; }; const MAP_VIEW_SAT = 'opslog.satMapView'; const fmtHz = (hz: number) => { if (!hz) return '—'; // Six decimals: a linear transponder is tuned to the hundred hertz, and the // Doppler correction moves the last three digits every second. 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([]); 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 [pass, setPass] = useState(null); const [tle, setTle] = useState<{ count: number; age_h: number; stale: boolean; custom: number } | null>(null); const [tracking, setTracking] = useState(null); const [err, setErr] = useState(''); // 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; // ── Data ───────────────────────────────────────────────────────────────── const loadBirds = useCallback(async () => { try { const list: Bird[] = (await GetSatelliteBirds()) as any; setBirds(list ?? []); } catch (e: any) { setErr(String(e?.message ?? e)); } }, []); 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(); 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(() => { 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]); // 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); 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)); } }; // ── 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); } 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'; // 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, shown]); // ── Render ─────────────────────────────────────────────────────────────── // 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 (
{/* 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. */}
{(bird?.transponders?.length ?? 0) > 1 && ( )} {tracking?.on && tracking.radio === 'downlink-only' && ( {t('sat.downlinkOnly')} )}
{/* Elements are maintenance, so only their AGE is here — and only when it has become a reason the panel might be wrong. */} {tle?.stale && {t('sat.tleStale')}}
{err &&
{err}
}
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its own panes and controls up to z-index 1000, which without a stacking context of their own float over Preferences and every dialog in the app — the map ends up on top of the very buttons that would close it. */}
{/* 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. */}
{bird?.name ?? '—'} {tp?.label ?? ''}
{bird?.geostationary ? (
{t('sat.geoHint')}
) : !pass?.has_pass ? (
{t('sat.noPassSoon')}
) : ( <>
{inPass ? t('sat.los') : t('sat.aos')} {fmtCountdown((inPass ? losMs : aosMs) - now)} {hhmmss(inPass ? pass.los : pass.aos)}Z
{/* Where in the pass we are. A bar because the useful question mid-pass is not the clock but "am I past the peak". */}
= 30} />
)}
{/* Where it is, right now. */}
{/* 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 && (
{tuning.range_rate < 0 ? : } {tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')} {Math.abs(tuning.range_rate).toFixed(2)} km/s
)} {/* Where the antenna is, beside where the satellite is. The two differing is a rotator still slewing; the two differing for a long time is a rotator that is stuck, and that is worth being able to see without walking outside. */} {tracking?.rot_on && (
{t('sat.antenna')} {fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)} {!tracking.rot_live && {t('sat.rotCommanded')}}
)}
{/* What to tune. */}
{!!tp?.mode && {tp.mode}} {!!tp?.ctcss && CTCSS {tp.ctcss.toFixed(1)}} {tp?.inverting && {t('sat.inverting')}} {tp?.linear && {Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz} {bird?.geostationary && {t('sat.geo')}}
{/* What is coming. */}
{t('sat.nextPasses')}
{passes.length === 0 && (
{t('sat.noPasses')}
)} {passes.map((p, i) => { const mins = inMin(p.aos); const running = mins <= 0 && Date.parse(p.los) > now; return ( ); })}
); } function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) { return (
{label}
{value} {!!sub && {sub}}
); } function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) { return (
{label}
{value}
); } // 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 }) { const shift = hz && nominal ? hz - nominal : 0; return (
{label} {fmtHz(hz)} {!!shift && ( {shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz )}
); }