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:
2026-09-07 22:36:48 +02:00
parent 76022ff91c
commit b478cbfd2a
6 changed files with 388 additions and 33 deletions
+64 -2
View File
@@ -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',