feat(sat): a wider frequency list, a resizable readout, one locator
Three things reported together from the tab. The shipped frequency plan went from eleven satellites to twenty-five: the eight Tevel FM cubesats, EO-88, AO-109, CAS-4A and 4B, TO-108, GreenCube's single-frequency digipeater, and QO-100's wideband transponder beside its narrowband one. It remains a starting point in a file the operator can correct — a transponder gets switched and no release should be needed to follow it — and the picker still lists every bird in the element set when the "with a plan" filter is unticked. The readout column drags to any width between 240 and 720 pixels, double-clicks back to its default, and folds away entirely. How much map against how much detail is the operator's call: watching a footprint cross an ocean and working a pass want opposite things. And the locator is no longer asked for twice. Passes are predicted from the station locator, which is set once in Station information; the field here was only ever for an antenna at another site, so it says so and sits folded away. Nobody should have to wonder which of two locators is in use. Also: "Driven by" is two columns wide. "OpsLog (EasyComm II)" did not fit in a third of the row, and a truncated choice is a choice that cannot be read.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
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 { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import {
|
||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass,
|
||||
@@ -13,6 +13,7 @@ import { gridToLatLon, splitAtAntimeridian } from '@/lib/maidenhead';
|
||||
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
|
||||
import { loadMapView, saveMapView } from '@/lib/mapView';
|
||||
import { loadMapBase, saveMapBase, MAP_BASE_SAT } from '@/lib/mapBase';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
@@ -63,6 +64,14 @@ type Track = {
|
||||
|
||||
const MAP_VIEW_SAT = 'opslog.satMapView';
|
||||
|
||||
// The readout column. Wide enough by default to hold a frequency to the hertz
|
||||
// without wrapping, and adjustable because how much map an operator wants
|
||||
// against how much detail is theirs to decide — a station watching a footprint
|
||||
// cross an ocean wants the map, one working a pass wants the numbers.
|
||||
const SIDE_W_KEY = 'opslog.satSideWidth';
|
||||
const SIDE_SHOWN_KEY = 'opslog.satSideShown';
|
||||
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
|
||||
|
||||
const fmtHz = (hz: number) => {
|
||||
if (!hz) return '—';
|
||||
// Six decimals: a linear transponder is tuned to the hundred hertz, and the
|
||||
@@ -253,6 +262,35 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
|
||||
const home = useMemo(() => gridToLatLon(myGrid), [myGrid]);
|
||||
|
||||
// ── The readout column ───────────────────────────────────────────────────
|
||||
|
||||
const [sideW, setSideW] = useState<number>(() => {
|
||||
const n = parseFloat(localStorage.getItem(SIDE_W_KEY) || '');
|
||||
return Number.isFinite(n) && n >= SIDE_W_MIN && n <= SIDE_W_MAX ? n : SIDE_W_DEFAULT;
|
||||
});
|
||||
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
|
||||
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
|
||||
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
|
||||
|
||||
// Dragging the grip. Measured from where the pointer STARTED rather than from
|
||||
// the container, and with the pointer captured — without the capture the map
|
||||
// underneath swallows the moves the instant the cursor crosses it.
|
||||
const startSideDrag = (e: React.PointerEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
const x0 = e.clientX;
|
||||
const w0 = sideW;
|
||||
const onMove = (ev: PointerEvent) => {
|
||||
setSideW(Math.min(SIDE_W_MAX, Math.max(SIDE_W_MIN, Math.round(w0 + (x0 - ev.clientX)))));
|
||||
};
|
||||
const onUp = () => {
|
||||
window.removeEventListener('pointermove', onMove);
|
||||
window.removeEventListener('pointerup', onUp);
|
||||
};
|
||||
window.addEventListener('pointermove', onMove);
|
||||
window.addEventListener('pointerup', onUp);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current || mapRef.current) return;
|
||||
const m = L.map(divRef.current, {
|
||||
@@ -417,6 +455,16 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
>
|
||||
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
|
||||
</select>
|
||||
{/* Put the whole window on the map. On a laptop the readout takes a
|
||||
third of the screen, and there are moments — watching a footprint
|
||||
cross an ocean — when the map IS the answer. */}
|
||||
<Button
|
||||
variant="ghost" size="sm" className="h-7 px-1.5"
|
||||
onClick={() => setSideShown((v) => !v)}
|
||||
title={sideShown ? t('sat.hideSide') : t('sat.showSide')}
|
||||
>
|
||||
{sideShown ? <PanelRightClose className="size-3.5" /> : <PanelRightOpen className="size-3.5" />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
|
||||
@@ -431,7 +479,21 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
<div ref={divRef} className="h-full w-full" />
|
||||
</div>
|
||||
|
||||
<div className="w-[21rem] shrink-0 flex flex-col gap-1 min-h-0">
|
||||
{sideShown && (
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
title={t('sat.sideWidthTip')}
|
||||
onPointerDown={startSideDrag}
|
||||
onDoubleClick={() => setSideW(SIDE_W_DEFAULT)}
|
||||
className="group relative shrink-0 w-2 cursor-col-resize flex items-center justify-center"
|
||||
>
|
||||
<span className="h-10 w-[3px] rounded-full bg-border group-hover:bg-primary transition-colors" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
|
||||
style={{ width: sideW }}>
|
||||
{/* 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',
|
||||
|
||||
@@ -4531,11 +4531,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
<div className="space-y-5 max-w-xl">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('satset.grid')}</Label>
|
||||
<Input className="font-mono" placeholder={t('satset.gridPlaceholder')}
|
||||
value={satCfg.grid ?? ''} onChange={(e) => set('grid', e.target.value.toUpperCase())} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('satset.altM')}</Label>
|
||||
<Input className="font-mono" value={String(satCfg.alt_m ?? 0)}
|
||||
@@ -4546,16 +4541,28 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<Input className="font-mono" value={String(satCfg.min_el ?? 10)}
|
||||
onChange={(e) => set('min_el', num(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('satset.gridHint')}</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('satset.windowH')}</Label>
|
||||
<Input className="font-mono" value={String(satCfg.window_h ?? 24)}
|
||||
onChange={(e) => set('window_h', num(e.target.value))} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t('satset.altHint')}</p>
|
||||
|
||||
{/* The locator is NOT repeated here: it is the station's, set once in
|
||||
Station information, and the passes are predicted from it. This is
|
||||
the exception — an antenna at another site — and it says so, so
|
||||
nobody has to wonder which of two locators is in use. */}
|
||||
<details className="text-sm">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
{t('satset.otherSite')}
|
||||
</summary>
|
||||
<div className="mt-2 flex items-center gap-3">
|
||||
<Input className="font-mono w-40" placeholder={t('satset.gridPlaceholder')}
|
||||
value={satCfg.grid ?? ''} onChange={(e) => set('grid', e.target.value.toUpperCase())} />
|
||||
<span className="text-xs text-muted-foreground">{t('satset.otherSiteHint')}</span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-3">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('satset.rotor')}</h4>
|
||||
@@ -4569,8 +4576,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
<>
|
||||
{/* Who drives the mast. Not a detail: a station already running
|
||||
PstRotator must NOT have OpsLog on the same cable as well. */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{/* Two columns wide: "OpsLog (EasyComm II)" does not fit in a
|
||||
third of the row, and a truncated choice is a choice an
|
||||
operator cannot read. */}
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>{t('satset.rotType')}</Label>
|
||||
<Select value={satCfg.rot_type || 'easycomm'} onValueChange={(v) => set('rot_type', v)}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
|
||||
@@ -595,10 +595,14 @@ const en: Dict = {
|
||||
'sat.geoHint': 'Geostationary: always there, no Doppler to correct. Point once and leave it.',
|
||||
'sat.noneFollowed': 'no satellite followed — choose some in Settings',
|
||||
'sat.tleStale': 'elements are old — refresh them in Settings',
|
||||
'sat.hideSide': 'Hide the readout — all map', 'sat.showSide': 'Show the readout',
|
||||
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
|
||||
'sec.satellite': 'Satellites',
|
||||
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
|
||||
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'your station’s',
|
||||
'satset.gridHint': 'Leave the locator empty to use your station’s own. Altitude is the antenna above sea level — it changes the horizon, and so the start and end of a low pass.',
|
||||
'satset.gridPlaceholder': 'e.g. JN18cx',
|
||||
'satset.altHint': 'Passes are predicted from your station locator (Station information). Altitude is the antenna above sea level — it changes the horizon, and so the start and end of a low pass. “Lowest pass” hides the ones that scrape the horizon and will never be a QSO.',
|
||||
'satset.otherSite': 'The satellite antenna is at another site…',
|
||||
'satset.otherSiteHint': 'Only fill this in if your satellite station is somewhere other than your logged locator. Empty means your station’s own.',
|
||||
'satset.altM': 'Altitude (m)', 'satset.minEl': 'Lowest pass (°)', 'satset.windowH': 'Predict ahead (hours)',
|
||||
'satset.autoTle': 'Fetch fresh elements at startup when they are more than three days old',
|
||||
'satset.rotor': 'Azimuth / elevation rotator',
|
||||
@@ -1182,10 +1186,14 @@ const fr: Dict = {
|
||||
'sat.geoHint': 'Géostationnaire : toujours là, aucun Doppler à corriger. On pointe une fois et on n’y touche plus.',
|
||||
'sat.noneFollowed': 'aucun satellite suivi — choisissez-en dans les Réglages',
|
||||
'sat.tleStale': 'éléments anciens — actualisez-les dans les Réglages',
|
||||
'sat.hideSide': 'Masquer le panneau — carte plein écran', 'sat.showSide': 'Afficher le panneau',
|
||||
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
|
||||
'sec.satellite': 'Satellites',
|
||||
'satset.hint': 'Où se trouve l’antenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans l’onglet Satellites.',
|
||||
'satset.grid': 'Locator', 'satset.gridPlaceholder': 'celui de la station',
|
||||
'satset.gridHint': 'Laissez le locator vide pour utiliser celui de votre station. L’altitude est celle de l’antenne au-dessus du niveau de la mer — elle change l’horizon, donc le début et la fin d’un passage rasant.',
|
||||
'satset.gridPlaceholder': 'ex. JN18cx',
|
||||
'satset.altHint': 'Les passages sont calculés depuis le locator de votre station (Informations station). L’altitude est celle de l’antenne au-dessus du niveau de la mer — elle change l’horizon, donc le début et la fin d’un passage rasant. « Passage minimal » masque ceux qui rasent l’horizon et ne feront jamais un QSO.',
|
||||
'satset.otherSite': 'L’antenne satellite est sur un autre site…',
|
||||
'satset.otherSiteHint': 'À remplir seulement si votre station satellite est ailleurs que le locator de votre log. Vide = celui de votre station.',
|
||||
'satset.altM': 'Altitude (m)', 'satset.minEl': 'Passage minimal (°)', 'satset.windowH': 'Prévoir sur (heures)',
|
||||
'satset.autoTle': 'Récupérer des éléments frais au démarrage quand ils ont plus de trois jours',
|
||||
'satset.rotor': 'Rotor azimut / élévation',
|
||||
|
||||
@@ -59,6 +59,7 @@ const PORTABLE_KEYS = [
|
||||
'opslog.clusterMuteWorked', // cluster/band map: no colour or badge on worked spots
|
||||
'opslog.clusterSlotHighlight', // cluster/band map: colour calls not worked on this band+mode
|
||||
'opslog.bandMapWidth', // docked band map: column width (px)
|
||||
'opslog.satSideWidth', 'opslog.satSideShown', // Satellites tab: readout column width, and whether it is shown
|
||||
'opslog.bandMapTabWidth', // Band map tab: shared card width (px)
|
||||
'opslog.bandMapZoom', // band map zoom (px/kHz step) remembered per band, as one {band: index} map
|
||||
'opslog.decodeColWidths', // FT decodes table: per-column widths (px), as one {col: px} map
|
||||
|
||||
Reference in New Issue
Block a user