feat(sat): the Satellites tab laid out again, and a visible ground track

Everything asked for and not delivered last time.

The sky plot moves to its own column on the LEFT of the map. It was
stacked in the readout column, where a plot that wants to be square
competed for width with the numbers and pushed the pass table off the
bottom of the screen — while the margin on the other side of the map sat
empty throughout. Its own width, dragged from its right edge, with
double-click to reset.

Every block of the readout column gets a heading and a chevron: the
pass, the position, what to tune, the pass list. Each remembers its own
state, because an operator working FM birds never looks at the linear
passband and one watching a schedule never looks at the range rate.
There were no headings at all before, which cost twice — nothing said
what a group of numbers was, and there was nowhere to put the control
that shuts it. A shut block keeps the one number it exists for in its
heading, and drops that badge again when open rather than repeating the
body a line below.

The tune panel now leads with the CENTRE of the passband. The
Doppler-corrected figure was the wrong number for a reference panel: it
moves every second, it is different for every operator, and it is not
what the frequency plan, the AMSAT tables or anybody on the air calls
the satellite's frequency. What the radio is actually on belongs beside
Tracking, where the radio is — it is already there — so this keeps the
correction only as the offset that explains the difference.

Badges rather than rows of grey words, in the app's own status tones, on
the things that decide something: the band of each frequency, the
Doppler offset, an inverting transponder, the passband width, and the
peak elevation of a pass.

And the ground track. It was `color: 'var(--info)'` on a map that
renders with preferCanvas, and a CSS variable is not a colour a canvas
strokeStyle can take — so the browser kept whatever was set last and the
track came out a pale near-white that vanished over the imagery and the
deserts alike. This is the same trap internal to GridSquareMap's own
comment: every other map in this app passes hex. Now a real colour over
a dark casing, the way a road is drawn, because one line cannot hold up
over both a pale sea and a dark continent but a line with an outline
can.
This commit is contained in:
2026-09-10 15:09:27 +02:00
parent e9816241e8
commit 3fe14c2c77
3 changed files with 301 additions and 54 deletions
+17
View File
@@ -1,4 +1,21 @@
[ [
{
"version": "0.27.24",
"en": [
"The Satellites tab is laid out again. The sky plot moves to its own column on the LEFT of the map, where a plot that wants to be square is not competing for width with the numbers — it used to sit in the readout column and push the pass table off the bottom of the screen, while the margin on the other side of the map stayed empty. Its width drags, and double-clicking the handle resets it.",
"Every block of the readout column now has a heading and shuts on its own: the pass, the position, what to tune, the pass list. Each one remembers whether it is open, because an operator working FM birds never looks at the linear passband and one watching a schedule never looks at the range rate. A shut block keeps the one thing worth knowing in its heading — the countdown, the elevation, the mode.",
"The frequency to tune to is the CENTRE of the passband, not the Doppler-corrected figure. The corrected one moves every second, differs for every operator, and is not what the frequency plan or anybody on the air calls the satellite's frequency; it belongs beside Tracking, where the radio is, and that is where it now lives. The correction stays here as the offset that explains the difference between the two.",
"More colour where it decides something: the band of each frequency, the Doppler offset, an inverting transponder, the passband width and the peak elevation of a pass are badges in the app's own status colours rather than rows of grey words.",
"The ground track is visible on every basemap. It was drawn in a CSS variable, which is not a colour a canvas can use, so the browser kept whatever it had and the track came out a pale near-white that disappeared over the imagery and the deserts alike. It is now a proper colour, drawn over a dark casing the way a road is on a map — one line cannot hold up over both a pale sea and a dark continent, but a line with an outline can."
],
"fr": [
"Longlet Satellites est réagencé. Le tracé du ciel passe dans sa propre colonne à GAUCHE de la carte, où un graphique qui veut être carré ne dispute plus la largeur aux chiffres — il occupait la colonne de droite et repoussait le tableau des passes hors de l’écran, alors que la marge de lautre côté de la carte restait vide. Sa largeur se règle à la souris, et un double-clic sur la poignée la réinitialise.",
"Chaque bloc de la colonne de droite a désormais un titre et se replie indépendamment : la passe, la position, ce quil faut accorder, la liste des passes. Chacun retient sil est ouvert, car qui travaille les satellites FM ne regarde jamais la bande passante linéaire, et qui surveille un calendrier ne regarde jamais la vitesse radiale. Un bloc replié conserve dans son titre la seule chose utile : le décompte, l’élévation, le mode.",
"La fréquence à accorder est le CENTRE de la bande passante, pas la valeur corrigée du Doppler. Celle-ci bouge chaque seconde, diffère pour chaque opérateur, et nest pas ce que le plan de fréquences ou nimporte qui sur lair appelle la fréquence du satellite ; elle a sa place à côté de Tracking, là où se trouve la radio, et cest là quelle est désormais. La correction reste ici, comme l’écart qui explique la différence entre les deux.",
"Plus de couleur là où elle décide de quelque chose : la bande de chaque fréquence, l’écart Doppler, un transpondeur inverseur, la largeur de bande passante et le pic d’élévation dune passe sont des pastilles aux couleurs d’état de lapplication, plutôt que des rangées de mots gris.",
"Le tracé au sol est visible sur tous les fonds de carte. Il était dessiné avec une variable CSS, qui nest pas une couleur exploitable par un canvas : le navigateur conservait ce quil avait et le tracé sortait dans un blanc pâle qui disparaissait aussi bien sur limagerie que sur les déserts. Cest maintenant une vraie couleur, tracée sur un liseré sombre comme on dessine une route sur une carte — une seule ligne ne peut pas tenir à la fois sur une mer claire et un continent sombre, une ligne avec un contour si."
]
},
{ {
"version": "0.27.23", "version": "0.27.23",
"date": "", "date": "",
+277 -53
View File
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import L from 'leaflet'; import L from 'leaflet';
import 'leaflet/dist/leaflet.css'; import 'leaflet/dist/leaflet.css';
import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass } from 'lucide-react'; import { Satellite as SatIcon, Radio, ArrowUp, ArrowDown, PanelRightClose, PanelRightOpen, Radar, Compass,
ChevronDown, Clock, Crosshair, SlidersHorizontal, ListOrdered } from 'lucide-react';
import { import {
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning, GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack, GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
@@ -73,6 +74,40 @@ const SIDE_W_KEY = 'opslog.satSideWidth';
const SIDE_SHOWN_KEY = 'opslog.satSideShown'; const SIDE_SHOWN_KEY = 'opslog.satSideShown';
const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720; const SIDE_W_DEFAULT = 336, SIDE_W_MIN = 240, SIDE_W_MAX = 720;
const SKY_SHOWN_KEY = 'opslog.satSkyShown'; const SKY_SHOWN_KEY = 'opslog.satSkyShown';
// The sky plot's column, to the LEFT of the map.
//
// It used to sit in the readout column, where it competed for width with the
// numbers and pushed the pass table off the bottom of the screen. There was
// an empty margin on the other side of the map the whole time, and a plot
// that wants to be square is exactly what belongs in a column of its own.
const SKY_W_KEY = 'opslog.satSkyWidth';
const SKY_W_DEFAULT = 260, SKY_W_MIN = 180, SKY_W_MAX = 480;
// Each block of the readout column, open or shut, remembered separately: an
// operator working FM birds never looks at the linear passband and one
// chasing a schedule never looks at the range rate.
const SEC_KEYS = {
pass: 'opslog.satSecPass',
where: 'opslog.satSecWhere',
tune: 'opslog.satSecTune',
passes: 'opslog.satSecPasses',
} as const;
type SecId = keyof typeof SEC_KEYS;
// The ground track, drawn canvas-safe.
//
// It was `var(--info)`, and this map renders with preferCanvas: a CSS
// variable handed to a canvas strokeStyle is not a colour, so the browser
// kept whatever was set last and the track came out a pale near-white that
// vanished over the imagery and the deserts alike. Every other map in this
// app passes hex for the same reason.
//
// Drawn twice: a dark casing underneath, then the bright line on top. That is
// how a road is drawn on a map, and for the same reason — one colour cannot
// hold up over both a pale sea and a dark continent, but a colour with an
// outline can.
const TRACK_INK = '#38bdf8';
const TRACK_CASING = '#0b1220';
// Four decimals — a hundred hertz, which is what a linear transponder is // Four decimals — a hundred hertz, which is what a linear transponder is
// actually tuned to. // actually tuned to.
@@ -469,10 +504,45 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0'); const [sideShown, setSideShown] = useState(() => localStorage.getItem(SIDE_SHOWN_KEY) !== '0');
useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]); useEffect(() => { writeUiPref(SIDE_W_KEY, String(Math.round(sideW))); }, [sideW]);
useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]); useEffect(() => { writeUiPref(SIDE_SHOWN_KEY, sideShown ? '1' : '0'); }, [sideShown]);
const [skyW, setSkyW] = useState(() => {
const n = parseFloat(localStorage.getItem(SKY_W_KEY) || '');
return Number.isFinite(n) && n >= SKY_W_MIN && n <= SKY_W_MAX ? n : SKY_W_DEFAULT;
});
useEffect(() => { writeUiPref(SKY_W_KEY, String(Math.round(skyW))); }, [skyW]);
// Open by default, every one of them: a panel that starts shut is a feature
// nobody finds. Shutting one is a decision, and it is remembered.
const [secOpen, setSecOpen] = useState<Record<SecId, boolean>>(() => ({
pass: localStorage.getItem(SEC_KEYS.pass) !== '0',
where: localStorage.getItem(SEC_KEYS.where) !== '0',
tune: localStorage.getItem(SEC_KEYS.tune) !== '0',
passes: localStorage.getItem(SEC_KEYS.passes) !== '0',
}));
const toggleSec = (id: SecId) => setSecOpen((m) => {
const next = { ...m, [id]: !m[id] };
writeUiPref(SEC_KEYS[id], next[id] ? '1' : '0');
return next;
});
// Dragging the grip. Measured from where the pointer STARTED rather than from // Dragging the grip. Measured from where the pointer STARTED rather than from
// the container, and with the pointer captured — without the capture the map // the container, and with the pointer captured — without the capture the map
// underneath swallows the moves the instant the cursor crosses it. // underneath swallows the moves the instant the cursor crosses it.
const startSkyDrag = (e: React.PointerEvent) => {
e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const x0 = e.clientX;
const w0 = skyW;
const onMove = (ev: PointerEvent) => {
// Plus, not minus: this handle is on the right of what it resizes.
setSkyW(Math.min(SKY_W_MAX, Math.max(SKY_W_MIN, Math.round(w0 + (ev.clientX - x0)))));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
const startSideDrag = (e: React.PointerEvent) => { const startSideDrag = (e: React.PointerEvent) => {
e.preventDefault(); e.preventDefault();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -562,7 +632,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
if (track.length > 1) { if (track.length > 1) {
const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number])); const pts = splitAtAntimeridian(track.map((p) => [p.lat, p.lon] as [number, number]));
L.polyline(pts as L.LatLngExpression[][], { L.polyline(pts as L.LatLngExpression[][], {
color: 'var(--info)', weight: 1.2, opacity: 0.7, dashArray: '4 4', smoothFactor: 0, color: TRACK_CASING, weight: 4, opacity: 0.4, smoothFactor: 0, interactive: false,
}).addTo(layer);
L.polyline(pts as L.LatLngExpression[][], {
color: TRACK_INK, weight: 1.8, opacity: 0.95, dashArray: '5 4', smoothFactor: 0, interactive: false,
}).addTo(layer); }).addTo(layer);
} }
const wanted = new Set(shown.map((b) => b.name)); const wanted = new Set(shown.map((b) => b.name));
@@ -807,6 +880,40 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>} {err && <div className="px-2 text-[11px] text-danger shrink-0">{err}</div>}
<div className="flex gap-1 flex-1 min-h-0"> <div className="flex gap-1 flex-1 min-h-0">
{/* The sky, seen from underneath it: the centre is straight up, the
rim is the horizon, north is at the top. One glance says whether
the pass comes over the roof or along the treeline.
To the LEFT of the map, in a column of its own. It was stacked in
the readout column, where a plot that wants to be square competed
for width with the numbers and pushed the pass table off the
bottom of the screen — while the margin on this side of the map
sat empty the whole time. */}
{skyShown && (
<div className="shrink-0 flex flex-col gap-1 min-h-0" style={{ width: skyW }}>
<div className="rounded-lg border border-border bg-card p-2">
<SkyPlot
track={sky}
az={tuning?.az ?? null}
el={tuning?.el ?? null}
name={bird?.name}
visible={!!tuning?.visible}
/>
</div>
</div>
)}
{skyShown && (
<div
role="separator"
aria-orientation="vertical"
title={t('sat.skyWidthTip')}
onPointerDown={startSkyDrag}
onDoubleClick={() => setSkyW(SKY_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>
)}
{/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its {/* The map. isolate is load-bearing, not tidiness: Leaflet stacks its
own panes and controls up to z-index 1000, which without a stacking own panes and controls up to z-index 1000, which without a stacking
context of their own float over Preferences and every dialog in the context of their own float over Preferences and every dialog in the
@@ -832,13 +939,29 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')} <div className={cn('shrink-0 flex flex-col gap-1 min-h-0', !sideShown && 'hidden')}
style={{ width: sideW }}> style={{ width: sideW }}>
{/* The pass. The first thing an operator looks at and the reason they {/* 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. */} sit down: how long have I got, and how high does it get.
<div className={cn('rounded-lg border bg-card p-2',
inPass ? 'border-success/60' : 'border-border')}> The countdown is repeated in the heading, so shutting this block
<div className="flex items-baseline justify-between gap-2"> still leaves the one number it exists for. */}
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span> <Section
<ModeBadge mode={tp?.mode} className="self-center" /> title={bird?.name ?? '—'}
icon={Clock}
open={secOpen.pass}
onToggle={() => toggleSec('pass')}
right={(
<div className="flex items-center gap-1">
<ModeBadge mode={tp?.mode} />
{/* Only while it is SHUT. Open, the countdown is already
there in full a line below, and a heading that repeats
the body is just noise. */}
{!secOpen.pass && !bird?.geostationary && pass?.has_pass && (
<Pill tone={inPass ? 'success' : 'muted'}>
{inPass ? t('sat.los') : t('sat.aos')} {fmtCountdown((inPass ? losMs : aosMs) - now)}
</Pill>
)}
</div> </div>
)}
>
{bird?.geostationary ? ( {bird?.geostationary ? (
<div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div> <div className="mt-1 text-[11px] text-muted-foreground">{t('sat.geoHint')}</div>
@@ -869,31 +992,32 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
<div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums"> <div className="mt-1.5 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} /> <PassBit label={t('sat.rise')} value={`${hhmm(pass.aos)} ${compass(pass.aos_az)}`} />
{/* The one number that decides whether the pass is worth
sitting down for, coloured like the pass table colours
it: a 70° pass overhead and a 12° scrape are not the
same evening. */}
<PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`} <PassBit label={t('sat.peak')} value={`${Math.round(pass.max_el)}° ${compass(pass.max_el_az)}`}
strong={pass.max_el >= 30} /> strong valueClass={elClass(pass.max_el)} />
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} /> <PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
</div> </div>
</> </>
)} )}
</div> </Section>
{/* The sky, seen from underneath it: the centre is straight up, the {/* Where it is, right now. Its elevation goes in the heading: above
rim is the horizon, north is at the top. One glance says whether or below the horizon is the one thing worth knowing with the
the pass comes over the roof or along the treeline. */} block shut. */}
{skyShown && ( <Section
<div className="rounded-lg border border-border bg-card p-2"> title={t('sat.secWhere')}
<SkyPlot icon={Crosshair}
track={sky} open={secOpen.where}
az={tuning?.az ?? null} onToggle={() => toggleSec('where')}
el={tuning?.el ?? null} right={tuning && !secOpen.where ? (
name={bird?.name} <Pill tone={tuning.visible ? 'success' : 'muted'}>
visible={!!tuning?.visible} {fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
/> </Pill>
</div> ) : undefined}
)} >
{/* Where it is, right now. */}
<div className="rounded-lg border border-border bg-card p-2">
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'} <Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} /> sub={tuning ? compass(tuning.az) : ''} />
@@ -940,26 +1064,25 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>} {!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div> </div>
)} )}
</div> </Section>
{/* What to tune. */} {/* What to tune. */}
<div className="rounded-lg border border-border bg-card p-2"> <Section
{/* The mode leads, because it decides everything below it. */} title={t('sat.secTune')}
icon={SlidersHorizontal}
open={secOpen.tune}
onToggle={() => toggleSec('tune')}
right={<ModeBadge mode={tp?.mode} />}
>
{/* What KIND of transponder, as badges rather than a row of grey
words: inverting decides which sideband to answer on, and a
passband width decides whether there is room to move. */}
<div className="flex items-center gap-1.5 mb-1.5"> <div className="flex items-center gap-1.5 mb-1.5">
<ModeBadge mode={tp?.mode} />
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span> <span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
<div className="flex-1" /> <div className="flex-1" />
{tp?.inverting && ( {tp?.inverting && <Pill tone="warning" title={t('sat.invertingHint')}>{t('sat.inverting')}</Pill>}
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span> {tp?.linear && <Pill tone="info">{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</Pill>}
)} {bird?.geostationary && <Pill>{t('sat.geo')}</Pill>}
{tp?.linear && (
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz
</span>
)}
{bird?.geostationary && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">{t('sat.geo')}</span>
)}
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@@ -990,14 +1113,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
)} )}
</div> </div>
)} )}
</div> </Section>
{/* What is coming. */} {/* What is coming. */}
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col"> <Section
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0"> title={t('sat.nextPasses')}
{t('sat.nextPasses')} icon={ListOrdered}
</div> open={secOpen.passes}
<div className="flex-1 min-h-0 overflow-auto"> onToggle={() => toggleSec('passes')}
grow
right={passes.length > 0 ? <Pill>{passes.length}</Pill> : undefined}
>
<div className="min-h-0">
{passes.length === 0 && idle.length === 0 && ( {passes.length === 0 && idle.length === 0 && (
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div> <div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
)} )}
@@ -1077,7 +1204,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
</table> </table>
)} )}
</div> </div>
</div> </Section>
</div> </div>
</div> </div>
</div> </div>
@@ -1098,11 +1225,75 @@ function Readout({ label, value, colour, sub }: { label: string; value: string;
); );
} }
function PassBit({ label, value, strong }: { label: string; value: string; strong?: boolean }) { // Pill is a small semantic badge. The tones are the app's own status tokens,
// so a warning here is the same colour as a warning everywhere else — the
// point of having them is that an operator learns one vocabulary, not one per
// panel.
const PILL_TONE: Record<string, string> = {
muted: 'text-muted-foreground border-border bg-muted/40',
success: 'text-success border-success/45 bg-success/10',
warning: 'text-warning border-warning/45 bg-warning/10',
caution: 'text-caution border-caution/45 bg-caution/10',
danger: 'text-danger border-danger/45 bg-danger/10',
info: 'text-info border-info/45 bg-info/10',
};
function Pill({ tone = 'muted', title, className, children }:
{ tone?: keyof typeof PILL_TONE | string; title?: string; className?: string; children: React.ReactNode }) {
return (
<span title={title}
className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border tabular-nums',
PILL_TONE[tone] ?? PILL_TONE.muted, className)}>
{children}
</span>
);
}
// Section is one collapsible block of the readout column.
//
// The blocks had no headings at all, which cost twice: nothing said what a
// group of numbers was, and there was nowhere to put the control that shuts
// it. An operator working FM birds never looks at the linear passband and one
// watching a schedule never looks at the range rate, so each one shuts on its
// own and stays shut.
//
// `right` is for a badge that must stay readable with the block CLOSED — the
// state of a pass, the mode being tuned. A heading that still answers the
// question is why shutting a block is worth doing.
function Section({ title, icon: Icon, open, onToggle, right, grow, children }: {
title: string;
icon: any;
open: boolean;
onToggle: () => void;
right?: React.ReactNode;
grow?: boolean;
children: React.ReactNode;
}) {
return (
<div className={cn('rounded-lg border border-border bg-card flex flex-col overflow-hidden',
grow && open && 'flex-1 min-h-0')}>
<button type="button" onClick={onToggle}
className="shrink-0 flex items-center gap-1.5 px-2 py-1 text-left hover:bg-accent/40 transition-colors">
<Icon className="size-3 shrink-0 text-muted-foreground" />
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground truncate">{title}</span>
<div className="flex-1" />
{right}
<ChevronDown className={cn('size-3 shrink-0 text-muted-foreground transition-transform', !open && '-rotate-90')} />
</button>
{open && (
<div className={cn('border-t border-border/60', grow ? 'flex-1 min-h-0 overflow-auto' : 'px-2 py-2')}>
{children}
</div>
)}
</div>
);
}
function PassBit({ label, value, strong, valueClass }:
{ label: string; value: string; strong?: boolean; valueClass?: string }) {
return ( return (
<div className="min-w-0"> <div className="min-w-0">
<div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div> <div className="text-[10px] uppercase tracking-wide text-muted-foreground truncate">{label}</div>
<div className={cn('truncate', strong && 'font-semibold text-foreground')}>{value}</div> <div className={cn('truncate', strong && 'font-semibold', valueClass ?? (strong ? 'text-foreground' : undefined))}>{value}</div>
</div> </div>
); );
} }
@@ -1110,14 +1301,47 @@ function PassBit({ label, value, strong }: { label: string; value: string; stron
// The corrected frequency large, the nominal one small beside it. Showing only // The corrected frequency large, the nominal one small beside it. Showing only
// one of them leaves an operator unable to tell a Doppler correction from a // one of them leaves an operator unable to tell a Doppler correction from a
// mistuned transponder. // mistuned transponder.
// satBand names the band a satellite frequency is in, for the badge.
//
// Only the bands satellites actually use, and by inspection rather than by
// asking the backend: it is a label beside a number that is already on
// screen, not a fact anything depends on.
function satBand(hz: number): string {
const mhz = hz / 1e6;
if (mhz >= 28 && mhz < 30) return '10m';
if (mhz >= 144 && mhz < 148) return '2m';
if (mhz >= 420 && mhz < 450) return '70cm';
if (mhz >= 1240 && mhz < 1300) return '23cm';
if (mhz >= 2300 && mhz < 2450) return '13cm';
if (mhz >= 10450 && mhz < 10500) return '3cm';
return '';
}
// FreqRow is the frequency to TUNE TO — the centre of the passband, not the
// Doppler-corrected one.
//
// It used to lead with the corrected figure, which is the wrong number to
// put in a reference panel: it moves every second, it is different for
// every operator, and it is not what the frequency plan, the AMSAT tables
// or anybody on the air calls the satellite's frequency. What the radio is
// actually on belongs beside Tracking, where the radio is, and that is
// where it now lives. The correction is still shown here, as the OFFSET
// that explains the difference between the two.
function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) { function FreqRow({ label, hz, nominal }: { label: string; hz: number; nominal: number }) {
const { t } = useI18n();
const shift = hz && nominal ? hz - nominal : 0; const shift = hz && nominal ? hz - nominal : 0;
const centre = nominal || hz;
const band = satBand(centre);
return ( return (
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">{label}</span> <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> <span className="text-base font-semibold tabular-nums">{fmtHz(centre)}</span>
{!!band && <Pill className="self-center">{band}</Pill>}
<div className="flex-1" />
{!!shift && ( {!!shift && (
<span className="text-[10px] tabular-nums text-muted-foreground">{fmtShift(shift)}</span> <Pill tone={shift > 0 ? 'success' : 'caution'} className="self-center" title={t('sat.shiftHint')}>
{fmtShift(shift)}
</Pill>
)} )}
</div> </div>
); );
+6
View File
@@ -607,6 +607,9 @@ const en: Dict = {
'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)', 'sat.antenna': 'Antenna', 'sat.rotCommanded': '(commanded — this controller does not report back)',
'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set', 'sat.aos': 'Rises in', 'sat.los': 'Sets in', 'sat.rise': 'Rise', 'sat.peak': 'Peak', 'sat.set': 'Set',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint', 'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Footprint',
'sat.secWhere': 'Position', 'sat.secTune': 'Tune to', 'sat.skyWidthTip': 'Drag to resize the sky plot, double-click to reset it',
'sat.shiftHint': 'Doppler correction — the radio is on the centre frequency plus this. The corrected figure is beside Tracking.',
'sat.invertingHint': 'The passband is turned over: transmit on lower sideband to come back on upper.',
'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude', 'sat.tipEl': 'Elevation', 'sat.tipAz': 'Azimuth', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
'sat.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed', 'sat.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed',
'sat.rotAzOnly': 'azimuth only', 'sat.rotAzOnly': 'azimuth only',
@@ -1230,6 +1233,9 @@ const fr: Dict = {
'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)', 'sat.antenna': 'Antenne', 'sat.rotCommanded': '(commandé — ce contrôleur ne répond pas)',
'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher', 'sat.aos': 'Lever dans', 'sat.los': 'Coucher dans', 'sat.rise': 'Lever', 'sat.peak': 'Culmination', 'sat.set': 'Coucher',
'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte', 'sat.range': 'Distance', 'sat.altitude': 'Altitude', 'sat.footprint': 'Empreinte',
'sat.secWhere': 'Position', 'sat.secTune': 'À accorder', 'sat.skyWidthTip': 'Glisser pour redimensionner le ciel, double-clic pour réinitialiser',
'sat.shiftHint': 'Correction Doppler — la radio est sur la fréquence centrale plus cette valeur. Le chiffre corrigé est à côté de Tracking.',
'sat.invertingHint': 'La bande passante est inversée : émettre en bande latérale inférieure pour revenir en supérieure.',
'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude', 'sat.tipEl': 'Élévation', 'sat.tipAz': 'Azimut', 'sat.tipRange': 'Distance', 'sat.tipAlt': 'Altitude',
'sat.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise', 'sat.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise',
'sat.rotAzOnly': 'azimut seul', 'sat.rotAzOnly': 'azimut seul',