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:
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import L from 'leaflet';
|
||||
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 {
|
||||
GetSatelliteBirds, GetSatellitePositions, GetSatellitePasses, GetSatelliteTuning,
|
||||
GetSatelliteGroundTrack, GetSatelliteTLEInfo, GetSatelliteNextPass, GetSatelliteSkyTrack,
|
||||
@@ -73,6 +74,40 @@ 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 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
|
||||
// actually tuned to.
|
||||
@@ -469,10 +504,45 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
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]);
|
||||
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
|
||||
// the container, and with the pointer captured — without the capture the map
|
||||
// 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) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||
@@ -562,7 +632,10 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
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,
|
||||
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);
|
||||
}
|
||||
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>}
|
||||
|
||||
<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
|
||||
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
|
||||
@@ -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')}
|
||||
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',
|
||||
inPass ? 'border-success/60' : 'border-border')}>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="font-medium text-sm truncate">{bird?.name ?? '—'}</span>
|
||||
<ModeBadge mode={tp?.mode} className="self-center" />
|
||||
</div>
|
||||
sit down: how long have I got, and how high does it get.
|
||||
|
||||
The countdown is repeated in the heading, so shutting this block
|
||||
still leaves the one number it exists for. */}
|
||||
<Section
|
||||
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>
|
||||
)}
|
||||
>
|
||||
|
||||
{bird?.geostationary ? (
|
||||
<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">
|
||||
<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)}`}
|
||||
strong={pass.max_el >= 30} />
|
||||
strong valueClass={elClass(pass.max_el)} />
|
||||
<PassBit label={t('sat.set')} value={`${hhmm(pass.los)} ${compass(pass.los_az)}`} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* 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. */}
|
||||
{skyShown && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Where it is, right now. */}
|
||||
<div className="rounded-lg border border-border bg-card p-2">
|
||||
{/* Where it is, right now. Its elevation goes in the heading: above
|
||||
or below the horizon is the one thing worth knowing with the
|
||||
block shut. */}
|
||||
<Section
|
||||
title={t('sat.secWhere')}
|
||||
icon={Crosshair}
|
||||
open={secOpen.where}
|
||||
onToggle={() => toggleSec('where')}
|
||||
right={tuning && !secOpen.where ? (
|
||||
<Pill tone={tuning.visible ? 'success' : 'muted'}>
|
||||
{fmtDeg(tuning.el)} {tuning.visible ? t('sat.up') : t('sat.below')}
|
||||
</Pill>
|
||||
) : undefined}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Readout label={t('sat.az')} value={tuning ? fmtDeg(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>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* What to tune. */}
|
||||
<div className="rounded-lg border border-border bg-card p-2">
|
||||
{/* The mode leads, because it decides everything below it. */}
|
||||
<Section
|
||||
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">
|
||||
<ModeBadge mode={tp?.mode} />
|
||||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? '—'}</span>
|
||||
<div className="flex-1" />
|
||||
{tp?.inverting && (
|
||||
<span className="shrink-0 text-[10px] uppercase tracking-wide text-warning">{t('sat.inverting')}</span>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
{tp?.inverting && <Pill tone="warning" title={t('sat.invertingHint')}>{t('sat.inverting')}</Pill>}
|
||||
{tp?.linear && <Pill tone="info">{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</Pill>}
|
||||
{bird?.geostationary && <Pill>{t('sat.geo')}</Pill>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
@@ -990,14 +1113,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* What is coming. */}
|
||||
<div className="rounded-lg border border-border bg-card flex-1 min-h-0 flex flex-col">
|
||||
<div className="px-2 py-1 text-[11px] font-medium text-muted-foreground border-b border-border shrink-0">
|
||||
{t('sat.nextPasses')}
|
||||
</div>
|
||||
<div className="flex-1 min-h-0 overflow-auto">
|
||||
<Section
|
||||
title={t('sat.nextPasses')}
|
||||
icon={ListOrdered}
|
||||
open={secOpen.passes}
|
||||
onToggle={() => toggleSec('passes')}
|
||||
grow
|
||||
right={passes.length > 0 ? <Pill>{passes.length}</Pill> : undefined}
|
||||
>
|
||||
<div className="min-h-0">
|
||||
{passes.length === 0 && idle.length === 0 && (
|
||||
<div className="p-2 text-[11px] text-muted-foreground">{t('sat.noPasses')}</div>
|
||||
)}
|
||||
@@ -1077,7 +1204,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
</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 (
|
||||
<div className="min-w-0">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
// one of them leaves an operator unable to tell a Doppler correction from a
|
||||
// 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 }) {
|
||||
const { t } = useI18n();
|
||||
const shift = hz && nominal ? hz - nominal : 0;
|
||||
const centre = nominal || hz;
|
||||
const band = satBand(centre);
|
||||
return (
|
||||
<div className="flex items-baseline gap-2">
|
||||
<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 && (
|
||||
<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>
|
||||
);
|
||||
|
||||
@@ -607,6 +607,9 @@ const en: Dict = {
|
||||
'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.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.tone': 'Tone', 'sat.toneHint': 'CTCSS on the uplink', 'sat.toneNone': 'no tone needed',
|
||||
'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.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.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.tone': 'Tonalité', 'sat.toneHint': 'CTCSS sur la montée', 'sat.toneNone': 'aucune tonalité requise',
|
||||
'sat.rotAzOnly': 'azimut seul',
|
||||
|
||||
Reference in New Issue
Block a user