feat(sat): the pass list becomes a table worth reading
Headings, because a column of numbers with nothing above it makes an operator work out what they are looking at every time. A real table, so the satellite column takes the width the longest name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in a fixed one. Colour where it carries meaning, and nowhere else. The maximum elevation is the quality of the pass, so it is coloured like one: a bird 70° overhead and one scraping 12° along the horizon are not the same evening, and the table should say so without the operator reading every number. A pass in progress is green, one starting within five minutes is amber. And a dot for the mode: FM and SSB call for a completely different set-up, and which the next pass is decides whether you reach for a handheld or for the whole station. The rotator's baud rate is a dropdown, like every other one in OpsLog.
This commit is contained in:
@@ -106,6 +106,38 @@ function fmtCountdown(ms: number): string {
|
||||
const COMPASS = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
||||
const compass = (deg: number) => COMPASS[Math.round(((deg % 360) + 360) % 360 / 45) % 8];
|
||||
|
||||
// How good a pass is, as a colour. A bird 70° overhead and one scraping 12°
|
||||
// along the horizon are not the same evening, and the table should say so
|
||||
// without the operator reading every number.
|
||||
function elClass(el: number): string {
|
||||
if (el >= 50) return 'text-success';
|
||||
if (el >= 25) return 'text-foreground';
|
||||
if (el >= 15) return 'text-caution';
|
||||
return 'text-muted-foreground';
|
||||
}
|
||||
|
||||
// The mode a satellite is worked in, as a dot: FM and SSB call for a completely
|
||||
// different set-up, and which of the two the next pass is decides whether the
|
||||
// operator reaches for a handheld or the whole station.
|
||||
const MODE_COLOUR: Record<string, string> = {
|
||||
FM: 'var(--info)',
|
||||
SSB: 'var(--success)',
|
||||
CW: 'var(--caution)',
|
||||
DATA: 'var(--warning)',
|
||||
};
|
||||
|
||||
function ModeDot({ mode }: { mode: string }) {
|
||||
const colour = MODE_COLOUR[mode];
|
||||
if (!colour) return null;
|
||||
return (
|
||||
<span
|
||||
title={mode}
|
||||
className="ml-1.5 inline-block size-1.5 rounded-full align-middle"
|
||||
style={{ backgroundColor: colour }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
const { t } = useI18n();
|
||||
const [birds, setBirds] = useState<Bird[]>([]);
|
||||
@@ -136,6 +168,12 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
}, [birds]);
|
||||
const bird = useMemo(() => birds.find((b) => b.name === sel) ?? null, [birds, sel]);
|
||||
const tp = bird?.transponders?.[tpIdx] ?? null;
|
||||
// The mode each satellite is worked in, for the pass table's dot. Its first
|
||||
// transponder: on a bird that has two, the first is the one it is known for.
|
||||
const modeOf = useMemo(() => {
|
||||
const m = new Map(birds.map((b) => [b.name, b.transponders?.[0]?.mode ?? ''] as const));
|
||||
return (name: string) => m.get(name) ?? '';
|
||||
}, [birds]);
|
||||
|
||||
// ── Data ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -606,29 +644,58 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
{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>
|
||||
);
|
||||
})}
|
||||
{passes.length > 0 && (
|
||||
// A real table, so the name column takes the width the longest
|
||||
// name needs — "ZHUHAI-1 OVS-1A" was cut to eight characters in
|
||||
// a fixed one — and the rest keeps its columns lined up under
|
||||
// headings that say what the numbers are.
|
||||
<table className="w-full text-[11px] tabular-nums">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
<th className="text-left font-medium px-2 py-1">{t('sat.thSat')}</th>
|
||||
<th className="text-left font-medium py-1">{t('sat.thAos')}</th>
|
||||
<th className="text-left font-medium py-1">{t('sat.thLos')}</th>
|
||||
<th className="text-right font-medium py-1">{t('sat.thMaxEl')}</th>
|
||||
<th className="text-right font-medium px-2 py-1">{t('sat.thIn')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{passes.map((p, i) => {
|
||||
const aos = Date.parse(p.aos);
|
||||
const running = aos <= now && Date.parse(p.los) > now;
|
||||
const soon = !running && aos - now < 5 * 60_000;
|
||||
return (
|
||||
<tr
|
||||
key={`${p.name}-${p.aos}-${i}`}
|
||||
onClick={() => setSel(p.name)}
|
||||
className={cn(
|
||||
'cursor-pointer hover:bg-accent/50 border-t border-border/40',
|
||||
p.name === sel && 'bg-accent/40',
|
||||
running && 'bg-success/10',
|
||||
)}
|
||||
>
|
||||
<td className="px-2 py-1 whitespace-nowrap">
|
||||
<span className={cn('font-medium', running && 'text-success')}>{p.name}</span>
|
||||
<ModeDot mode={modeOf(p.name)} />
|
||||
</td>
|
||||
<td className="py-1 whitespace-nowrap">{hhmm(p.aos)}</td>
|
||||
<td className="py-1 whitespace-nowrap text-muted-foreground">{hhmm(p.los)}</td>
|
||||
{/* The elevation is the quality of the pass, so it is
|
||||
coloured like one: a 70° pass overhead and a 12°
|
||||
scrape along the horizon are not the same evening. */}
|
||||
<td className={cn('py-1 text-right font-medium', elClass(p.max_el))}>
|
||||
{Math.round(p.max_el)}°
|
||||
</td>
|
||||
<td className={cn('px-2 py-1 text-right whitespace-nowrap',
|
||||
running ? 'text-success font-medium' : soon ? 'text-warning' : 'text-muted-foreground')}>
|
||||
{running ? t('sat.now') : fmtCountdown(aos - now)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4651,8 +4651,14 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('satset.rotBaud')}</Label>
|
||||
<Input className="font-mono" value={String(satCfg.rot_baud ?? 9600)}
|
||||
onChange={(e) => set('rot_baud', num(e.target.value))} />
|
||||
<Select value={String(satCfg.rot_baud || 9600)} onValueChange={(v) => set('rot_baud', parseInt(v, 10) || 9600)}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[1200, 2400, 4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
|
||||
<SelectItem key={b} value={String(b)}>{b}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -597,6 +597,7 @@ const en: Dict = {
|
||||
'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.',
|
||||
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
|
||||
'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.gridPlaceholder': 'e.g. JN18cx',
|
||||
@@ -1188,6 +1189,7 @@ const fr: Dict = {
|
||||
'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.',
|
||||
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
|
||||
'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.gridPlaceholder': 'ex. JN18cx',
|
||||
|
||||
Reference in New Issue
Block a user