From 680bf410fe7850327a560f94c389d2c0b2309fe8 Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 7 Sep 2026 10:56:28 +0200 Subject: [PATCH] feat(sat): the Satellites tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- changelog.json | 10 + frontend/src/App.tsx | 41 ++ frontend/src/components/SatellitePanel.tsx | 462 +++++++++++++++++++++ frontend/src/lib/i18n.tsx | 12 + frontend/src/lib/uiPref.ts | 2 +- 5 files changed, 526 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/SatellitePanel.tsx diff --git a/changelog.json b/changelog.json index b4f53ba..dfcb77c 100644 --- a/changelog.json +++ b/changelog.json @@ -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", "date": "", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6a2d1e5..e1811c1 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -80,6 +80,7 @@ import { ConfirmDialog } from '@/components/ConfirmDialog'; import { SettingsModal } from '@/components/SettingsModal'; import { FTMapPanel } from '@/components/FTMapPanel'; import { DXpeditionsPanel } from '@/components/DXpeditionsPanel'; +import { SatellitePanel } from '@/components/SatellitePanel'; import { FirstRunModal } from '@/components/FirstRunModal'; import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; @@ -1354,6 +1355,17 @@ export default function App() { } const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '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() { setDxpedTabOpen(true); writeUiPref('opslog.dxpedTab', '1'); @@ -5210,6 +5222,7 @@ export default function App() { { name: 'tools', label: t('menu.tools'), items: [ { type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' }, { 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('station.title'), action: 'tools.station' }, { 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.station': setStationTabOpen(true); setActiveTab('station'); break; case 'tools.dxped': openDxpedTab(); break; + case 'tools.sat': openSatTab(); break; case 'tools.decodes': openDecodesTab(); break; case 'tools.ftmap': openFtmapTab(); break; case 'tools.grids': openGridsTab(); break; @@ -8160,6 +8174,21 @@ export default function App() { )} + {satTabOpen && ( + + {t('sat.tab')} + { e.stopPropagation(); }} + onClick={(e) => { e.stopPropagation(); closeSatTab(); }} + > + + + + )} {ftmapTabOpen && ( {t('ftmap.tab')} @@ -8808,6 +8837,18 @@ export default function App() { )} )} + {satTabOpen && ( + + {/* 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' && ( +
+ +
+ )} +
+ )} {ftmapTabOpen && ( {activeTab === 'ftmap' && ( diff --git a/frontend/src/components/SatellitePanel.tsx b/frontend/src/components/SatellitePanel.tsx new file mode 100644 index 0000000..2bd9f1c --- /dev/null +++ b/frontend/src/components/SatellitePanel.tsx @@ -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([]); + const [sel, setSel] = useState(() => localStorage.getItem('opslog.satSelected') || ''); + const [tpIdx, setTpIdx] = useState(0); + const [positions, setPositions] = useState([]); + const [passes, setPasses] = useState([]); + const [tuning, setTuning] = useState(null); + const [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([]); + + 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(null); + const mapRef = useRef(null); + const layerRef = useRef(null); + const baseRef = useRef(null); + const labelsRef = useRef(null); + const [basemap, setBasemap] = useState(() => + (localStorage.getItem('opslog.satMapBase') as BasemapKey) || 'light'); + const saved = useRef(loadMapView(MAP_VIEW_SAT)); + const [track, setTrack] = useState([]); + + const home = useMemo(() => gridToLatLon(myGrid), [myGrid]); + + useEffect(() => { + if (!divRef.current || mapRef.current) return; + const m = L.map(divRef.current, { + zoomControl: true, attributionControl: true, + worldCopyJump: false, preferCanvas: true, + // Opened on the station, not on the Atlantic: the passes that matter are + // the ones over the operator's own head. + center: saved.current ? [saved.current.lat, saved.current.lon] : [home?.lat ?? 25, home?.lon ?? 0], + zoom: saved.current ? saved.current.zoom : 3, + minZoom: 2, + }); + m.on('moveend', () => { + const c = m.getCenter(); + saveMapView(MAP_VIEW_SAT, c.lat, c.lng, m.getZoom()); + }); + mapRef.current = m; + layerRef.current = L.layerGroup().addTo(m); + const ro = new ResizeObserver(() => m.invalidateSize({ animate: false })); + ro.observe(divRef.current); + const settle = window.setTimeout(() => m.invalidateSize({ animate: false }), 100); + return () => { + window.clearTimeout(settle); + ro.disconnect(); + m.remove(); + mapRef.current = null; + layerRef.current = null; + }; + }, [home?.lat, home?.lon]); + + useEffect(() => { + const m = mapRef.current; + if (!m) return; + baseRef.current?.remove(); labelsRef.current?.remove(); + const bm = BASEMAPS[basemap]; + const opts: L.TileLayerOptions = { + maxNativeZoom: bm.maxNativeZoom, noWrap: true, + bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)), + }; + baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m); + if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m); + localStorage.setItem('opslog.satMapBase', basemap); + }, [basemap]); + + // The selected bird's path over the ground, redrawn when the selection + // changes and every couple of minutes as it walks off the front of it. + useEffect(() => { + if (!sel) { setTrack([]); return; } + let live = true; + const load = async () => { + try { + const pts: Position[] = ((await GetSatelliteGroundTrack(sel, 100)) as any) ?? []; + if (live) setTrack(pts); + } catch { if (live) setTrack([]); } + }; + load(); + const id = window.setInterval(load, 120_000); + return () => { live = false; window.clearInterval(id); }; + }, [sel]); + + useEffect(() => { + const layer = layerRef.current; + if (!layer) return; + layer.clearLayers(); + if (home) { + L.circleMarker([home.lat, home.lon], { + radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1, + }).bindTooltip(myGrid, { direction: 'top' }).addTo(layer); + } + if (track.length > 1) { + const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number])); + L.polyline(pts as L.LatLngExpression[][], { + color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0, + }).addTo(layer); + } + 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 ( +
+ {/* Header: what to track, and what we know about the elements. */} +
+ + + {(bird?.transponders?.length ?? 0) > 1 && ( + + )} + {bird && ( + + )} +
+ + {tleLabel}{tle?.custom ? ` · ${t('sat.tleCustom', { n: tle.custom })}` : ''} + + + + +
+ + {err &&
{err}
} + +
+ {/* The map. */} +
+
+
+ + {/* The readout and the pass list. */} +
+
+
+ {bird?.name ?? '—'} + {tp?.label ?? ''} +
+ +
+ + +
+ +
+ + +
+ +
+ {tuning && tuning.range_km > 0 && {Math.round(tuning.range_km)} km} + {tuning && {tuning.range_rate > 0 ? '+' : ''}{tuning.range_rate.toFixed(2)} km/s} + {!!tp?.ctcss && CTCSS {tp.ctcss.toFixed(1)}} + {tp?.inverting && {t('sat.inverting')}} + {bird?.geostationary && {t('sat.geo')}} +
+
+ +
+
+ {t('sat.nextPasses')} +
+
+ {passes.length === 0 && ( +
{t('sat.noPasses')}
+ )} + {passes.map((p, i) => { + const mins = inMin(p.aos); + const now = mins <= 0 && Date.parse(p.los) > Date.now(); + return ( + + ); + })} +
+
+
+
+
+ ); +} + +function Readout({ label, value, colour }: { label: string; value: string; colour?: string }) { + return ( +
+
{label}
+
+ {value} +
+
+ ); +} + +// 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 ( +
+ {label} + {fmtHz(hz)} + {!!shift && ( + + {shift > 0 ? '+' : '−'}{Math.abs(Math.round(shift))} Hz + + )} +
+ ); +} diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 6dd1441..3cf19e8 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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.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: ', + '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 = { @@ -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.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 : ', + '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 n’est 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 d’un satellite qu’aucun 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 d’utilisable dans ce texte.', }; const dicts: Record = { en, fr }; diff --git a/frontend/src/lib/uiPref.ts b/frontend/src/lib/uiPref.ts index b3795a6..39c80c9 100644 --- a/frontend/src/lib/uiPref.ts +++ b/frontend/src/lib/uiPref.ts @@ -31,7 +31,7 @@ const PORTABLE_KEYS = [ '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 // 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.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)