Two things belong in different places, and they were in one. Settings → Satellites now holds the setup: which satellites to follow — the same two-column shape as the awards, for the same reason, since a feed carries two hundred birds and an operator works six — and the orbital elements, their age, the fetch, and pasting your own. Following none still means every satellite with both elements and a frequency plan, so somebody who has not chosen yet is not handed an empty tab. The panel keeps only what a pass needs. A countdown to AOS, or to LOS once it is up, because that is the number that decides whether you sit down; a bar for where in the pass you are, since mid-pass the useful question is not the clock but whether you are past the peak; rise, peak and set with compass directions, because "rises SW" is a direction to look in and 213° is arithmetic. Distance, altitude and footprint. And approaching or receding, which is the sign of the whole Doppler correction and the only thing that explains why the frequencies are moving the way they are. The countdowns run in the browser from two timestamps. Predicting a pass steps the orbit across a day thirty seconds at a time, which is not something to do once a second for a clock the page can keep itself.
618 lines
28 KiB
TypeScript
618 lines
28 KiB
TypeScript
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<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 [pass, setPass] = useState<PassInfo | 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 [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<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);
|
||
}
|
||
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 (
|
||
<div className="flex flex-col h-full min-h-0 gap-1 p-1">
|
||
{/* 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. */}
|
||
<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)}
|
||
>
|
||
{shown.length === 0 && <option value="">{t('sat.noneFollowed')}</option>}
|
||
{shown.map((b) => (
|
||
<option key={b.name} value={b.name} disabled={!b.has_elements && !b.geostationary}>
|
||
{b.name}{b.has_elements || b.geostationary ? '' : ` — ${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>
|
||
)}
|
||
<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" />
|
||
{/* Elements are maintenance, so only their AGE is here — and only when
|
||
it has become a reason the panel might be wrong. */}
|
||
{tle?.stale && <span className="text-[11px] text-warning">{t('sat.tleStale')}</span>}
|
||
<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. 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. */}
|
||
<div className="relative isolate z-0 flex-1 min-w-0 rounded-lg overflow-hidden border border-border">
|
||
<div ref={divRef} className="h-full w-full" />
|
||
</div>
|
||
|
||
<div className="w-[21rem] shrink-0 flex flex-col gap-1 min-h-0">
|
||
{/* 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. */}
|
||
<div className={cn('rounded-lg border bg-card p-2',
|
||
inPass ? 'border-success/60' : 'border-border')}>
|
||
<div className="flex items-baseline justify-between gap-2">
|
||
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
|
||
</div>
|
||
|
||
{bird?.geostationary ? (
|
||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
|
||
) : !pass?.has_pass ? (
|
||
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.noPassSoon')}</div>
|
||
) : (
|
||
<>
|
||
<div className="mt-1 flex items-baseline gap-2">
|
||
<span className={cn('text-[10px] uppercase tracking-wide',
|
||
inPass ? 'text-success' : 'text-muted-foreground')}>
|
||
{inPass ? t('sat.los') : t('sat.aos')}
|
||
</span>
|
||
<span className={cn('text-xl font-semibold tabular-nums leading-none',
|
||
inPass && 'text-success')}>
|
||
{fmtCountdown((inPass ? losMs : aosMs) - now)}
|
||
</span>
|
||
<span className="text-[11px] text-muted-foreground tabular-nums ml-auto">
|
||
{hhmmss(inPass ? pass.los : pass.aos)}Z
|
||
</span>
|
||
</div>
|
||
|
||
{/* Where in the pass we are. A bar because the useful question
|
||
mid-pass is not the clock but "am I past the peak". */}
|
||
<div className="mt-1.5 h-1 rounded-full bg-muted overflow-hidden">
|
||
<div className="h-full bg-success transition-[width] duration-1000 ease-linear"
|
||
style={{ width: `${Math.round(progress * 100)}%` }} />
|
||
</div>
|
||
|
||
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
|
||
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
|
||
strong={pass.max_el >= 30} />
|
||
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Where it is, right now. */}
|
||
<div className="rounded-lg border border-border bg-card p-2">
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
|
||
sub={tuning ? compass(tuning.az) : ''} />
|
||
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
|
||
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
|
||
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
|
||
</div>
|
||
|
||
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
|
||
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
|
||
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
|
||
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
|
||
</div>
|
||
|
||
{/* 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 && (
|
||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
|
||
{tuning.range_rate < 0
|
||
? <ArrowUp className="size-3 text-success" />
|
||
: <ArrowDown className="size-3 text-warning" />}
|
||
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
|
||
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* 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 && (
|
||
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
|
||
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
|
||
<span className="font-medium">{fmtDeg(tracking.rot_az)} / {fmtDeg(tracking.rot_el)}</span>
|
||
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* What to tune. */}
|
||
<div className="rounded-lg border border-border bg-card p-2">
|
||
<div className="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-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
||
{!!tp?.mode && <span>{tp.mode}</span>}
|
||
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
|
||
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
||
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
|
||
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
||
</div>
|
||
</div>
|
||
|
||
{/* What is coming. */}
|
||
<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 running = mins <= 0 && Date.parse(p.los) > 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',
|
||
running && '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-16 text-right text-muted-foreground">
|
||
{running ? t('sat.now') : fmtCountdown(Date.parse(p.aos) - now)}
|
||
</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Readout({ label, value, colour, sub }: { label: string; value: string; colour?: string; sub?: 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="flex items-baseline gap-1.5">
|
||
<span className="text-lg font-semibold tabular-nums leading-tight" style={colour ? { color: colour } : undefined}>
|
||
{value}
|
||
</span>
|
||
{!!sub && <span className="text-[10px] text-muted-foreground">{sub}</span>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) {
|
||
return (
|
||
<div className="min-w-0">
|
||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
|
||
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<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>
|
||
);
|
||
}
|