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.
517 lines
22 KiB
TypeScript
517 lines
22 KiB
TypeScript
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>
|
||
);
|
||
}
|