feat(sat): the Satellites tab

Three questions answered at once, because on a pass there is no time to
go looking for any of them: where the bird is, when the next one comes,
and what to tune. The map draws each satellite's footprint — the honest
answer to "can I hear it", since everything inside the circle has the
satellite above its horizon — and the selected one's path over the
ground. The pass list is every favourite in time order, the one in
progress in green.

The readout shows the corrected frequency large and the nominal one
beneath it. Only one of them, and an operator cannot tell a Doppler
correction from a mistuned transponder.

The map opens on the station rather than the Atlantic, and remembers
where it was left like the others. The panel is mounted only while its
tab is visible: it asks for the tuning once a second, and there is no
reason to compute an orbit nobody is looking at.
This commit is contained in:
2026-09-07 10:56:28 +02:00
parent 1009d06a4c
commit 680bf410fe
5 changed files with 526 additions and 1 deletions
+10
View File
@@ -1,4 +1,14 @@
[ [
{
"version": "0.27.17",
"date": "",
"en": [
"[NEW] Satellites. A new tab (Tools → Satellites) tracks the amateur birds: a map with each satellite's footprint and the selected one's path over the ground, the next passes with their maximum elevation, and — for the satellite you are on — the azimuth, the elevation and the Doppler-corrected downlink and uplink. Orbital elements come from Celestrak (with a mirror behind it) and are kept on disk, so the tab is full the moment it opens even with no internet; elements for a bird no feed carries yet can be pasted in and survive every refresh. The shipped frequency list covers the FM and linear satellites and QO-100, and lives in a file you can correct yourself when a transponder is switched."
],
"fr": [
"[NOUVEAU] Satellites. Un nouvel onglet (Outils → Satellites) suit les satellites amateurs : une carte avec l'empreinte de chacun et la trace au sol de celui qui est sélectionné, les prochains passages avec leur élévation maximale, et — pour le satellite en cours — l'azimut, l'élévation et les fréquences de descente et de montée corrigées de l'effet Doppler. Les éléments orbitaux viennent de Celestrak (avec un miroir derrière) et sont conservés sur disque : l'onglet est rempli dès son ouverture, même sans internet. Les éléments d'un satellite qu'aucun flux ne diffuse encore peuvent être collés à la main et survivent à chaque mise à jour. La liste de fréquences fournie couvre les satellites FM et linéaires ainsi que QO-100, dans un fichier que vous pouvez corriger vous-même quand un transpondeur change de mode."
]
},
{ {
"version": "0.27.16", "version": "0.27.16",
"date": "", "date": "",
+41
View File
@@ -80,6 +80,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal'; import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel'; import { FTMapPanel } from '@/components/FTMapPanel';
import { DXpeditionsPanel } from '@/components/DXpeditionsPanel'; import { DXpeditionsPanel } from '@/components/DXpeditionsPanel';
import { SatellitePanel } from '@/components/SatellitePanel';
import { FirstRunModal } from '@/components/FirstRunModal'; import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal'; import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap'; import { BandMap } from '@/components/BandMap';
@@ -1354,6 +1355,17 @@ export default function App() {
} }
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1'); const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1'); const [dxpedTabOpen, setDxpedTabOpen] = useState(() => localStorage.getItem('opslog.dxpedTab') === '1');
const [satTabOpen, setSatTabOpen] = useState(() => localStorage.getItem('opslog.satTab') === '1');
function openSatTab() {
setSatTabOpen(true);
writeUiPref('opslog.satTab', '1');
setActiveTab('sat');
}
function closeSatTab() {
setSatTabOpen(false);
writeUiPref('opslog.satTab', '0');
setActiveTab((t) => (t === 'sat' ? 'recent' : t));
}
function openDxpedTab() { function openDxpedTab() {
setDxpedTabOpen(true); setDxpedTabOpen(true);
writeUiPref('opslog.dxpedTab', '1'); writeUiPref('opslog.dxpedTab', '1');
@@ -5210,6 +5222,7 @@ export default function App() {
{ name: 'tools', label: t('menu.tools'), items: [ { name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('dxp.tab'), action: 'tools.dxped' }, { type: 'item', label: t('dxp.tab'), action: 'tools.dxped' },
{ type: 'item', label: t('sat.tab'), action: 'tools.sat' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' }, { type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' }, { type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' }, { type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
@@ -5267,6 +5280,7 @@ export default function App() {
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break; case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break; case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.dxped': openDxpedTab(); break; case 'tools.dxped': openDxpedTab(); break;
case 'tools.sat': openSatTab(); break;
case 'tools.decodes': openDecodesTab(); break; case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break; case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break; case 'tools.grids': openGridsTab(); break;
@@ -8160,6 +8174,21 @@ export default function App() {
</span> </span>
</TabsTrigger> </TabsTrigger>
)} )}
{satTabOpen && (
<TabsTrigger value="sat" className="gap-1.5">
{t('sat.tab')}
<span
role="button"
aria-label="Close Satellites"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeSatTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{ftmapTabOpen && ( {ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5"> <TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')} {t('ftmap.tab')}
@@ -8808,6 +8837,18 @@ export default function App() {
)} )}
</TabsContent> </TabsContent>
)} )}
{satTabOpen && (
<TabsContent value="sat" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{/* Mounted only while it is the visible tab: the panel polls the
tuning once a second, and there is no reason to compute an
orbit for a tab nobody is looking at. */}
{activeTab === 'sat' && (
<div className="h-full w-full min-h-0">
<SatellitePanel myGrid={station.my_grid} />
</div>
)}
</TabsContent>
)}
{ftmapTabOpen && ( {ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden"> <TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && ( {activeTab === 'ftmap' && (
+462
View File
@@ -0,0 +1,462 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { RefreshCw, Star, Satellite as SatIcon, ClipboardPaste } from 'lucide-react';
import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, RefreshSatelliteTLE, AddSatelliteElements,
GetSatSettings, SaveSatSettings,
} 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;
};
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 [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]);
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>
)}
<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>
);
}
+12
View File
@@ -576,6 +576,12 @@ const en: Dict = {
'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh', 'aud.testPtt': 'Test PTT', 'aud.pttPort': 'PTT COM port', 'aud.pickPort': 'Pick a COM port', 'aud.selectPort': '— select —', 'aud.refresh': 'Refresh',
'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop', 'aud.msgPlaceholder': 'Message {n} label (CQ, report, 73…)', 'aud.holdRec': '● Hold to rec', 'aud.recordingNow': '● Recording…', 'aud.play': '▶ Play', 'aud.stop': '■ Stop',
'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ', 'aud.errPttTest': 'PTT test: ', 'aud.errRecord': 'Record: ', 'aud.errSave': 'Save: ', 'aud.errPlay': 'Play: ',
'sat.tab': 'Satellites', 'sat.title': 'Satellites', 'sat.az': 'Azimuth', 'sat.el': 'Elevation', 'sat.down': 'Down', 'sat.up': 'Up',
'sat.nextPasses': 'Next passes', 'sat.noPasses': 'No pass above your minimum elevation in the window — or your locator is not set.', 'sat.now': 'now',
'sat.refreshTle': 'Elements', 'sat.tleAge': '{n} satellites · {h} h old', 'sat.tleNone': 'no elements yet — fetch them', 'sat.tleCustom': '{n} of your own',
'sat.noElements': 'no elements', 'sat.inverting': 'inverting', 'sat.geo': 'geostationary', 'sat.favTip': 'Track this satellite by default',
'sat.paste': 'Paste…', 'sat.pasteTip': 'Paste elements for a satellite no feed carries yet. They are kept in their own file and survive every refresh.',
'sat.pastePrompt': 'Paste the elements (name, then the two lines):', 'sat.pasteNone': 'Nothing usable in that text.',
}; };
const fr: Dict = { const fr: Dict = {
@@ -1115,6 +1121,12 @@ const fr: Dict = {
'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser', 'aud.testPtt': 'Tester le PTT', 'aud.pttPort': 'Port COM du PTT', 'aud.pickPort': 'Choisir un port COM', 'aud.selectPort': '— choisir —', 'aud.refresh': 'Actualiser',
'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter', 'aud.msgPlaceholder': 'Libellé du message {n} (CQ, report, 73…)', 'aud.holdRec': '● Maintenir pour enreg.', 'aud.recordingNow': '● Enregistrement…', 'aud.play': '▶ Lire', 'aud.stop': '■ Arrêter',
'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ', 'aud.errPttTest': 'Test PTT : ', 'aud.errRecord': 'Enregistrement : ', 'aud.errSave': 'Sauvegarde : ', 'aud.errPlay': 'Lecture : ',
'sat.tab': 'Satellites', 'sat.title': 'Satellites', 'sat.az': 'Azimut', 'sat.el': 'Élévation', 'sat.down': 'Desc.', 'sat.up': 'Mont.',
'sat.nextPasses': 'Prochains passages', 'sat.noPasses': 'Aucun passage au-dessus de votre élévation minimale sur la fenêtre — ou votre locator nest pas renseigné.', 'sat.now': 'en cours',
'sat.refreshTle': 'Éléments', 'sat.tleAge': '{n} satellites · {h} h', 'sat.tleNone': 'aucun élément — récupérez-les', 'sat.tleCustom': 'dont {n} à vous',
'sat.noElements': 'sans éléments', 'sat.inverting': 'inverseur', 'sat.geo': 'géostationnaire', 'sat.favTip': 'Suivre ce satellite par défaut',
'sat.paste': 'Coller…', 'sat.pasteTip': 'Collez les éléments dun satellite quaucun flux ne diffuse encore. Ils sont conservés dans leur propre fichier et survivent à chaque mise à jour.',
'sat.pastePrompt': 'Collez les éléments (nom, puis les deux lignes) :', 'sat.pasteNone': 'Rien dutilisable dans ce texte.',
}; };
const dicts: Record<Lang, Dict> = { en, fr }; const dicts: Record<Lang, Dict> = { en, fr };
+1 -1
View File
@@ -31,7 +31,7 @@ const PORTABLE_KEYS = [
'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom) 'opslog.mapView', // Main map: remembered free-pan view (lat/lon/zoom)
// The same, for the FT decodes map and the grid-square map: a view an // The same, for the FT decodes map and the grid-square map: a view an
// operator set up is theirs, and it should follow the folder like the rest. // operator set up is theirs, and it should follow the folder like the rest.
'opslog.ftMapView', 'opslog.gridMapView', 'opslog.ftMapView', 'opslog.gridMapView', 'opslog.satMapView',
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing 'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots 'opslog.groupDigitalSlots', // matrix + cluster: all digital modes count as ONE (DXCC-style) instead of per-mode slots
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane) 'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)