import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass, ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react'; import { GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack, StartSatelliteTracking, StopSatelliteTracking, GetSatelliteTracking, RetargetSatelliteTracking, } 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 { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase'; import { writeUiPref } from '@/lib/uiPref'; import { SkyPlot, type SkyPoint } from '@/components/SkyPlot'; 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; range_km: number; alt_km: number; radio: string; // "sat" | "downlink-only" | "" error: string; rot_on: boolean; rot_az: number; rot_el: number; rot_live: boolean; rot_az_only: boolean; }; const MAP_VIEW_SAT = 'opslog.satMapView'; // The readout column. Wide enough by default to hold a frequency to the hertz // without wrapping, and adjustable because how much map an operator wants // against how much detail is theirs to decide — a station watching a footprint // cross an ocean wants the map, one working a pass wants the numbers. const SIDE_W_KEY = 'opslog.satSideWidth'; const SIDE_SHOWN_KEY = 'opslog.satSideShown'; const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720; const SKY_SHOWN_KEY = 'opslog.satSkyShown'; // The sky plot's column, to the LEFT of the map. // // It used to sit in the readout column, where it competed for width with the // numbers and pushed the pass table off the bottom of the screen. There was // an empty margin on the other side of the map the whole time, and a plot // that wants to be square is exactly what belongs in a column of its own. const SKY_W_KEY = 'opslog.satSkyWidth'; const SKY_W_DEFAULT = 260, SKY_W_MIN = 180, SKY_W_MAX = 480; // Each block of the readout column, open or shut, remembered separately: an // operator working FM birds never looks at the linear passband and one // chasing a schedule never looks at the range rate. const SEC_KEYS = { pass: 'opslog.satSecPass', where: 'opslog.satSecWhere', tune: 'opslog.satSecTune', passes: 'opslog.satSecPasses', } as const; type SecId = keyof typeof SEC_KEYS; // The ground track, drawn canvas-safe. // // It was `var(--info)`, and this map renders with preferCanvas: a CSS // variable handed to a canvas strokeStyle is not a colour, so the browser // kept whatever was set last and the track came out a pale near-white that // vanished over the imagery and the deserts alike. Every other map in this // app passes hex for the same reason. // // Drawn twice: a dark casing underneath, then the bright line on top. That is // how a road is drawn on a map, and for the same reason — one colour cannot // hold up over both a pale sea and a dark continent, but a colour with an // outline can. const TRACK_INK = '#38bdf8'; const TRACK_CASING = '#0b1220'; // Four decimals — a hundred hertz, which is what a linear transponder is // actually tuned to. // // It used to be six, and the last two digits changed every tick: the Doppler // moves about sixty hertz a second on 70 cm, so the display was a blur of // numbers nobody could read and nobody needed. The RADIO still gets the whole // figure — the correction is computed and sent to the hertz — this is only how // much of it is worth putting in front of an operator. The shift beside it, in // kilohertz, is where the fine movement shows. const fmtHz = (hz: number) => { if (!hz) return '—'; return (hz / 1e6).toFixed(4).replace(/(\d)(?=(\d{3})+\.)/g, '$1 '); }; // The Doppler shift, as an operator would say it: hertz while it is small // enough to say in hertz, kilohertz once it is not. "+9741 Hz" is four digits // of precision on a number that is only ever read as "about ten kilohertz". const fmtShift = (hz: number) => { const sign = hz > 0 ? '+' : '−'; const a = Math.abs(hz); if (a < 1000) return `${sign}${Math.round(a)} Hz`; return `${sign}${(a / 1000).toFixed(1)} kHz`; }; 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]; // How good a pass is, as a colour. A bird 70° overhead and one scraping 12° // along the horizon are not the same evening, and the table should say so // without the operator reading every number. function elClass(el: number): string { if (el >= 50) return 'text-success'; if (el >= 25) return 'text-foreground'; if (el >= 15) return 'text-caution'; return 'text-muted-foreground'; } // The mode a satellite is worked in, as a dot: FM and SSB call for a completely // different set-up, and which of the two the next pass is decides whether the // operator reaches for a handheld or the whole station. const MODE_COLOUR: Record = { FM: 'var(--info)', SSB: 'var(--success)', CW: 'var(--caution)', DATA: 'var(--warning)', }; // escapeHtml, because a satellite name comes from data/satellites.json, which // the operator edits by hand. A stray "<" there must not be able to break the // tooltip it lands in. const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); // satTip is what hovering a satellite on the map says. // // The dot alone answered "there it is" and nothing else — the name, an // elevation and an altitude, none of which decides anything. What decides // whether to reach for the radio is how long is left, how high it will get and // where to point: so the pass is here, and reading it costs a hover instead of // selecting the bird and looking somewhere else on the screen. function satTip(p: Position, pass: Pass | undefined, t: (k: string) => string): string { const row = (label: string, value: string) => `
${label}${value}
`; const out: string[] = [`
${escapeHtml(p.name)}
`]; if (p.el > 0) { out.push(row(t('sat.tipEl'), `${fmtDeg(p.el)}`)); out.push(row(t('sat.tipAz'), `${fmtDeg(p.az)} ${compass(p.az)}`)); } else { out.push(`
${t('sat.tipBelow')}
`); } // Closing or opening: the sign of the range rate is the difference between a // pass about to start being useful and one already going away. const trend = p.range_rate < -0.05 ? ' ↓' : p.range_rate > 0.05 ? ' ↑' : ''; out.push(row(t('sat.tipRange'), fmtKm(p.range_km) + trend)); out.push(row(t('sat.tipAlt'), fmtKm(p.alt_km))); if (pass) { const aos = Date.parse(pass.aos), los = Date.parse(pass.los), now = Date.now(); if (now >= aos && now < los) { out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${fmtCountdown(los - now)}`)); } else { out.push(row(t('sat.tipAos'), `${hhmm(pass.aos)} · ${fmtCountdown(aos - now)} · ${compass(pass.aos_az)}`)); out.push(row(t('sat.tipLos'), `${hhmm(pass.los)} · ${compass(pass.los_az)}`)); } out.push(row(t('sat.tipMaxEl'), `${fmtDeg(pass.max_el)} ${compass(pass.max_el_az)}`)); } else { // No pass inside the prediction window. Worth saying: an empty space here // reads as a bug, and "nothing in the next 24 hours" is an answer. out.push(`
${t('sat.tipNoPass')}
`); } return out.join(''); } // ModeBadge says FM or SSB where it cannot be missed. // // The mode used to be one word in a grey 11-pixel footnote under the // frequencies, and it is not a footnote: FM and SSB are two different evenings. // One is a channel, a tone and a handheld; the other is a passband, a beam and a // VFO that has to be walked as the Doppler moves. An operator who reads the // wrong one calls into silence. function ModeBadge({ mode, className }: { mode?: string; className?: string }) { if (!mode) return null; const colour = MODE_COLOUR[mode] ?? 'var(--muted-foreground)'; return ( {mode} ); } function ModeDot({ mode }: { mode: string }) { const colour = MODE_COLOUR[mode]; if (!colour) return null; return ( ); } 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([]); // The next pass per satellite, for the map tooltips. The list is already // ordered by AOS across every bird, so the first entry for a name is its next // one — no second prediction run for what is already on screen. const nextPassOf = useMemo(() => { const m = new Map(); for (const p of passes) if (!m.has(p.name)) m.set(p.name, p); return m; }, [passes]); 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 [sky, setSky] = useState([]); const [skyShown, setSkyShown] = useState(() => localStorage.getItem(SKY_SHOWN_KEY) !== '0'); 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]); // The satellites you follow that have NO pass in the table. // // They are the reason the table was not the whole list: QO-100 never has a // pass because it never sets, a bird whose elements have not arrived cannot // be predicted at all, and one whose next pass falls outside the prediction // window is simply beyond it. Left out, those three looked like satellites // OpsLog had lost — so they are listed at the end, each saying which of the // three it is, and clicking one selects it exactly like a pass row. const idle = useMemo(() => { const withPass = new Set(passes.map((p) => p.name)); return shown.filter((b) => !withPass.has(b.name)); }, [shown, passes]); const tp = bird?.transponders?.[tpIdx] ?? null; // The mode each satellite is worked in, for the pass table's dot. Its first // transponder: on a bird that has two, the first is the one it is known for. const modeOf = useMemo(() => { const m = new Map(birds.map((b) => [b.name, b.transponders?.[0]?.mode ?? ''] as const)); return (name: string) => m.get(name) ?? ''; }, [birds]); // ── 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]); // Saving the satellite settings. The followed list, the lowest pass and the // locator all change what belongs here, and this tab is normally open behind // the settings window while they are edited. The selection repairs itself: // a satellite that is no longer followed drops out of the dropdown, and the // effect below moves to the first one that is. useEffect(() => EventsOn('sat:settings', () => { loadBirds(); loadPasses(); }), [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]); // The pass drawn across the sky. Once a minute is plenty: the SHAPE of a // pass does not change while it happens — only the marker on it moves, and // that comes from the tuning poll a second at a time. useEffect(() => { if (!sel || !skyShown) { setSky([]); return; } let live = true; const load = async () => { try { const pts: SkyPoint[] = ((await GetSatelliteSkyTrack(sel, 120)) as any) ?? []; if (live) setSky(pts); } catch { if (live) setSky([]); } }; load(); const id = window.setInterval(load, 60_000); return () => { live = false; window.clearInterval(id); }; }, [sel, skyShown]); useEffect(() => { writeUiPref(SKY_SHOWN_KEY, skyShown ? '1' : '0'); }, [skyShown]); // 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)); } }; // Changing satellite WHILE tracking moves the radio to the new one at once. // // The selection here is the display's; the tracker held its own and went on // following what it was started with, so two birds up at the same time meant // switching between them and watching the frequencies stay on the first. // Stopping and restarting worked, and is also how a Flex throws away and // rebuilds both its slices for nothing. // // Guarded on tracking being on, so selecting a satellite with the radio idle // stays what it has always been: a look, not a command. const trackingOn = !!tracking?.on; useEffect(() => { if (!trackingOn || !sel) return; RetargetSatelliteTracking(sel, tpIdx) .then(async () => setTracking((await GetSatelliteTracking()) as any)) .catch((e: any) => setErr(String(e?.message ?? e))); }, [sel, tpIdx, trackingOn]); // ── Map ────────────────────────────────────────────────────────────────── const divRef = useRef(null); const mapRef = useRef(null); const layerRef = useRef(null); const baseRef = useRef(null); // Where the pointer is over the map, in container pixels. // // The satellite layer is rebuilt every five seconds as the birds move, and a // rebuilt marker is a new marker: the tooltip the operator was reading closed // itself, over and over, which made the hover detail useless exactly when it // was being used. Knowing where the pointer is lets the redraw reopen the // tooltip of the dot it is still on — and only that one, so nothing is left // hanging open once the mouse has moved away. const mouseRef = useRef(null); const labelsRef = useRef(null); const [basemap, setBasemap] = useState(() => loadMapBase(MAP_BASE_SAT, 'light')); const saved = useRef(loadMapView(MAP_VIEW_SAT)); const [track, setTrack] = useState([]); const home = useMemo(() => gridToLatLon(myGrid), [myGrid]); // ── The readout column ─────────────────────────────────────────────────── const [sideW, setSideW] = useState(() => { const n = parseFloat(localStorage.getItem(SIDE_W_KEY) || ''); return Number.isFinite(n) && n >= SIDE_W_MIN && n <= SIDE_W_MAX ? n : SIDE_W_DEFAULT; }); const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0'); useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]); useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]); const [skyW, setSkyW] = useState(() => { const n = parseFloat(localStorage.getItem(SKY_W_KEY) || ''); return Number.isFinite(n) && n >= SKY_W_MIN && n <= SKY_W_MAX ? n : SKY_W_DEFAULT; }); useEffect(() => { writeUiPref(SKY_W_KEY, String(Math.round(skyW))); }, [skyW]); // Open by default, every one of them: a panel that starts shut is a feature // nobody finds. Shutting one is a decision, and it is remembered. const [secOpen, setSecOpen] = useState>(() => ({ pass: localStorage.getItem(SEC_KEYS.pass) !== '0', where: localStorage.getItem(SEC_KEYS.where) !== '0', tune: localStorage.getItem(SEC_KEYS.tune) !== '0', passes: localStorage.getItem(SEC_KEYS.passes) !== '0', })); const toggleSec = (id: SecId) => setSecOpen((m) => { const next = { ...m, [id]: !m[id] }; writeUiPref(SEC_KEYS[id], next[id] ? '1' : '0'); return next; }); // Dragging the grip. Measured from where the pointer STARTED rather than from // the container, and with the pointer captured — without the capture the map // underneath swallows the moves the instant the cursor crosses it. const startSkyDrag = (e: React.PointerEvent) => { e.preventDefault(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); const x0 = e.clientX; const w0 = skyW; const onMove = (ev: PointerEvent) => { // Plus, not minus: this handle is on the right of what it resizes. setSkyW(Math.min(SKY_W_MAX, Math.max(SKY_W_MIN, Math.round(w0 + (ev.clientX - x0))))); }; const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); }; const startSideDrag = (e: React.PointerEvent) => { e.preventDefault(); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); const x0 = e.clientX; const w0 = sideW; const onMove = (ev: PointerEvent) => { setSideW(Math.min(SIDE_W_MAX, Math.max(SIDE_W_MIN, Math.round(w0 + (x0 - ev.clientX))))); }; const onUp = () => { window.removeEventListener('pointermove', onMove); window.removeEventListener('pointerup', onUp); }; window.addEventListener('pointermove', onMove); window.addEventListener('pointerup', onUp); }; 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()); }); m.on('mousemove', (e: L.LeafletMouseEvent) => { mouseRef.current = e.containerPoint; }); m.on('mouseout', () => { mouseRef.current = null; }); 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); saveMapBase(MAP_BASE_SAT, 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: TRACK_CASING, weight: 4, opacity: 0.4, smoothFactor: 0, interactive: false, }).addTo(layer); L.polyline(pts as L.LatLngExpression[][], { color: TRACK_INK, weight: 1.8, opacity: 0.95, dashArray: '5 4', smoothFactor: 0, interactive: false, }).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' : '#94a3b8'; // The footprint is the honest answer to "can I hear it": everything inside // the circle has the satellite above its horizon. // // Drawn for the SELECTED bird only. A footprint is thousands of kilometres // across, so a dozen of them overlap into a wash of circles that hides the // coastline, the ground track and the satellites themselves — and the // question it answers is only ever asked about the one being worked. if (chosen) { L.circle([p.lat, p.lon], { radius: p.footprint_km * 1000, color: colour, weight: 1.2, opacity: 0.7, fillColor: colour, fillOpacity: 0.1, }).addTo(layer); } // Two rings and not one. The map is a street map on one station and a // dark satellite image on the next, and a single-stroke dot disappears // into one of them — a pale marker on pale terrain, a grey one on a black // ocean. A dark halo under a white ring reads on both, which is what an // unselected satellite needs: it is precisely the one nobody is looking // straight at. const r = chosen ? 7 : up ? 6 : 5; L.circleMarker([p.lat, p.lon], { radius: r + 1.5, color: '#000', weight: 2, opacity: 0.45, fill: false, interactive: false, }).addTo(layer); const dot = L.circleMarker([p.lat, p.lon], { radius: r, color: '#fff', weight: 2, fillColor: colour, fillOpacity: 1, }) .bindTooltip(satTip(p, nextPassOf.get(p.name), t), { direction: 'top', className: 'sat-tip', offset: [0, -6], }) .on('click', () => setSel(p.name)) .addTo(layer); // Was the pointer on this dot before the redraw replaced it? Then put the // tooltip back, with the numbers it has just refreshed. const map = mapRef.current; if (map && mouseRef.current) { const at = map.latLngToContainerPoint([p.lat, p.lon]); if (at.distanceTo(mouseRef.current) <= r + 3) dot.openTooltip(); } // A name beside the ones that are UP. The map can carry a dozen birds and // labelling them all is a map nobody can read; the two or three above the // horizon are the ones an operator is choosing between right now, and // hovering each grey dot in turn to find them is the work this saves. // // Its own non-interactive marker rather than a permanent tooltip on the // dot: Leaflet keeps ONE tooltip per layer, so a permanent label would // take the place of the hover detail — and the detail is the point. if (up || chosen) { L.marker([p.lat, p.lon], { icon: L.divIcon({ className: 'sat-name-label', html: `${escapeHtml(p.name)}`, iconSize: [0, 0], iconAnchor: [-(r + 5), 6], }), interactive: false, keyboard: false, }).addTo(layer); } } }, [positions, track, home?.lat, home?.lon, myGrid, sel, shown, nextPassOf, t]); // ── Render ─────────────────────────────────────────────────────────────── // The pass, as a countdown and a bar. Both derived here from two timestamps, // so they move every second without asking Go anything. // Is the antenna still on its way? The rotator is asked where it is every // three seconds and a mast takes tens of seconds to cross a pass, so a // difference between where it is and where the satellite is means it is // moving — which is exactly what a number alone cannot show, and the // difference between "on its way" and "stuck" is the whole reason to look. const antennaMoving = !!tracking?.rot_on && !!tracking.rot_live && Math.abs(((tracking.az - tracking.rot_az + 540) % 360) - 180) > 3; 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 && ( )} {/* Also in the header, so the mode and the tone survive hiding the readout column — which is exactly what an operator does when they want the map full width during a pass. */} {tp?.mode === 'FM' && !!tp.ctcss && ( {tp.ctcss.toFixed(1)} )} {tracking?.on && tracking.radio === 'downlink-only' && ( {t('sat.downlinkOnly')} )} {/* What the station is actually doing, beside the button that started it. During a pass an operator watches the radio and the antenna, not a column on the far side of the window — and that column is the first thing they hide to get the map full width. */} {tracking?.on && (
{/* Where the bird IS, which is not where the antenna is pointing: these three say whether the pass is worth calling on, and the rotator group further along says whether the mast has caught up with them. Elevation goes dim below the horizon, so a satellite still being tracked on its way up cannot be read as workable. */} {Math.round(tracking.az)}° / {tracking.el.toFixed(1)}° {tracking.range_km > 0 && ( {Math.round(tracking.range_km).toLocaleString()} km )} {tracking.alt_km > 0 && ( ↑{Math.round(tracking.alt_km).toLocaleString()} km )} {fmtHz(tracking.down_hz)} {!!tracking.up_hz && ( {fmtHz(tracking.up_hz)} )} {tracking.rot_on && ( {/* The needle spins while the antenna is slewing. A rotator takes tens of seconds to cross a pass, and the difference between "on its way" and "stuck" is the whole reason to look at it — a number alone cannot show movement. */} {Math.round(tracking.rot_az)}° {!tracking.rot_az_only && ` / ${Math.round(tracking.rot_el)}°`} )}
)}
{/* 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')}} {/* Put the whole window on the map. On a laptop the readout takes a third of the screen, and there are moments — watching a footprint cross an ocean — when the map IS the answer. */}
{err &&
{err}
}
{/* The sky, seen from underneath it: the centre is straight up, the rim is the horizon, north is at the top. One glance says whether the pass comes over the roof or along the treeline. To the LEFT of the map, in a column of its own. It was stacked in the readout column, where a plot that wants to be square competed for width with the numbers and pushed the pass table off the bottom of the screen — while the margin on this side of the map sat empty the whole time. */} {skyShown && (
)} {skyShown && (
setSkyW(SKY_W_DEFAULT)} className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center" >
)} {/* 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. */}
{sideShown && (
setSideW(SIDE_W_DEFAULT)} className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center" >
)}
{/* 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. The countdown is repeated in the heading, so shutting this block still leaves the one number it exists for. */}
toggleSec('pass')} right={(
{/* Only while it is SHUT. Open, the countdown is already there in full a line below, and a heading that repeats the body is just noise. */} {!secOpen.pass && !bird?.geostationary && pass?.has_pass && ( {inPass ? t('sat.los') : t('sat.aos')} {fmtCountdown((inPass ? losMs : aosMs) - now)} )}
)} > {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". */}
{/* The one number that decides whether the pass is worth sitting down for, coloured like the pass table colours it: a 70° pass overhead and a 12° scrape are not the same evening. */}
)}
{/* Where it is, right now. Its elevation goes in the heading: above or below the horizon is the one thing worth knowing with the block shut. */}
toggleSec('where')} right={tuning && !secOpen.where ? ( {fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')} ) : undefined} >
{/* 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')} {/* No elevation when none is being driven: an undriven zero draws an antenna lying on the horizon, which is a bearing and not the absence of one. */} {tracking.rot_az_only ? fmtDeg(tracking.rot_az) : `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`} {tracking.rot_az_only && {t('sat.rotAzOnly')}} {!tracking.rot_live && {t('sat.rotCommanded')}}
)}
{/* What to tune. */}
toggleSec('tune')} right={} > {/* What KIND of transponder, as badges rather than a row of grey words: inverting decides which sideband to answer on, and a passband width decides whether there is room to move. */}
{tp?.label ?? '—'}
{tp?.inverting && {t('sat.inverting')}} {tp?.linear && {Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz} {bird?.geostationary && {t('sat.geo')}}
{/* The tone, on the FM birds, with the same weight as a frequency. It IS one, as far as the outcome goes: a repeater called without its tone does not answer, and the operator hears an empty channel and concludes the satellite is not up. Said explicitly when there is none, too — a blank line cannot tell "no tone" from "OpsLog does not know". */} {tp?.mode === 'FM' && (
{t('sat.tone')} {tp.ctcss ? ( <> {tp.ctcss.toFixed(1)} Hz {t('sat.toneHint')} ) : ( {t('sat.toneNone')} )}
)}
{/* What is coming. */}
toggleSec('passes')} grow right={passes.length > 0 ? {passes.length} : undefined} >
{passes.length === 0 && idle.length === 0 && (
{t('sat.noPasses')}
)} {(passes.length > 0 || idle.length > 0) && ( // A real table, so the name column takes the width the longest // name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in // a fixed one — and the rest keeps its columns lined up under // headings that say what the numbers are. {passes.map((p, i) => { const aos = Date.parse(p.aos); const running = aos <= now && Date.parse(p.los) > now; const soon = !running && aos - now < 5 * 60_000; return ( setSel(p.name)} className={cn( 'cursor-pointer hover:bg-accent/50 border-t border-border/40', p.name === sel && 'bg-accent/40', running && 'bg-success/10', )} > {/* The elevation is the quality of the pass, so it is coloured like one: a 70° pass overhead and a 12° scrape along the horizon are not the same evening. */} ); })} {/* The rest of what you follow, so the table IS the list: nothing you can select is missing from it. */} {idle.map((b) => { const why = b.geostationary ? t('sat.alwaysUp') : !b.has_elements ? t('sat.noElements') : t('sat.noPassWindow'); return ( setSel(b.name)} className={cn('cursor-pointer hover:bg-accent/50 border-t border-border/40', b.name === sel && 'bg-accent/40')} > ); })}
{t('sat.thSat')} {t('sat.thAos')} {t('sat.thLos')} {t('sat.thMaxEl')} {t('sat.thIn')}
{p.name} {hhmm(p.aos)} {hhmm(p.los)} {Math.round(p.max_el)}° {running ? t('sat.now') : fmtCountdown(aos - now)}
{b.name} {why}
)}
); } function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: string }) { return (
{label}
{value} {!!sub && {sub}}
); } // Pill is a small semantic badge. The tones are the app's own status tokens, // so a warning here is the same colour as a warning everywhere else — the // point of having them is that an operator learns one vocabulary, not one per // panel. const PILL_TONE: Record = { muted: 'text-muted-foreground border-border bg-muted/40', success: 'text-success border-success/45 bg-success/10', warning: 'text-warning border-warning/45 bg-warning/10', caution: 'text-caution border-caution/45 bg-caution/10', danger: 'text-danger border-danger/45 bg-danger/10', info: 'text-info border-info/45 bg-info/10', }; function Pill({ tone = 'muted', title, className, children }: { tone?: keyof typeof PILL_TONE | string; title?: string; className?: string; children: React.ReactNode }) { return ( {children} ); } // Section is one collapsible block of the readout column. // // The blocks had no headings at all, which cost twice: nothing said what a // group of numbers was, and there was nowhere to put the control that shuts // it. An operator working FM birds never looks at the linear passband and one // watching a schedule never looks at the range rate, so each one shuts on its // own and stays shut. // // `right` is for a badge that must stay readable with the block CLOSED — the // state of a pass, the mode being tuned. A heading that still answers the // question is why shutting a block is worth doing. function Section({ title, icon: Icon, open, onToggle, right, grow, children }: { title: string; icon: any; open: boolean; onToggle: () => void; right?: React.ReactNode; grow?: boolean; children: React.ReactNode; }) { return (
{open && (
{children}
)}
); } function PassBit({ label, value, strong, valueClass }: { label: string; value: string; strong?: boolean; valueClass?: string }) { 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. // satBand names the band a satellite frequency is in, for the badge. // // Only the bands satellites actually use, and by inspection rather than by // asking the backend: it is a label beside a number that is already on // screen, not a fact anything depends on. function satBand(hz: number): string { const mhz = hz / 1e6; if (mhz >= 28 && mhz < 30) return '10m'; if (mhz >= 144 && mhz < 148) return '2m'; if (mhz >= 420 && mhz < 450) return '70cm'; if (mhz >= 1240 && mhz < 1300) return '23cm'; if (mhz >= 2300 && mhz < 2450) return '13cm'; if (mhz >= 10450 && mhz < 10500) return '3cm'; return ''; } // FreqRow is the frequency to TUNE TO — the centre of the passband, not the // Doppler-corrected one. // // It used to lead with the corrected figure, which is the wrong number to // put in a reference panel: it moves every second, it is different for // every operator, and it is not what the frequency plan, the AMSAT tables // or anybody on the air calls the satellite's frequency. What the radio is // actually on belongs beside Tracking, where the radio is, and that is // where it now lives. The correction is still shown here, as the OFFSET // that explains the difference between the two. function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) { const { t } = useI18n(); const shift = hz && nominal ? hz - nominal : 0; const centre = nominal || hz; const band = satBand(centre); return (
{label} {fmtHz(centre)} {!!band && {band}}
{!!shift && ( 0 ? 'success' : 'caution'} className="self-center" title={t('sat.shiftHint')}> {fmtShift(shift)} )}
); }