feat(sat): the mode and the CTCSS tone where they can be seen
Both were already on screen, in a grey eleven-pixel footnote under the frequencies, which is not where either of them belongs. FM and SSB are two different evenings. One is a channel, a tone and a handheld; the other is a passband, a beam and a VFO walked as the Doppler moves. So the mode is a coloured badge — in the readout, in the transponder list where a satellite offering both makes the choice matter, and in the header so it survives hiding the readout column, which is exactly what an operator does when they want the map full width during a pass. The tone gets the weight of a frequency because that is what it is worth: a repeater called without it does not answer, and what the operator hears is an empty channel and concludes the satellite is not up. And when a bird needs no tone it now says so — a blank line could not tell "no tone" from "OpsLog does not know", and the difference is a pass. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
@@ -175,6 +175,26 @@ function satTip(p: Position, pass: Pass | undefined, t: (k: string) => string):
|
||||
return out.join('');
|
||||
}
|
||||
|
||||
// ModeBadge says FM or SSB where it cannot be missed.
|
||||
//
|
||||
// The mode used to be one word in a grey 11-pixel footnote under the
|
||||
// frequencies, and it is not a footnote: FM and SSB are two different evenings.
|
||||
// One is a channel, a tone and a handheld; the other is a passband, a beam and a
|
||||
// VFO that has to be walked as the Doppler moves. An operator who reads the
|
||||
// wrong one calls into silence.
|
||||
function ModeBadge({ mode, className }: { mode?: string; className?: string }) {
|
||||
if (!mode) return null;
|
||||
const colour = MODE_COLOUR[mode] ?? 'var(--muted-foreground)';
|
||||
return (
|
||||
<span
|
||||
className={cn('shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide border', className)}
|
||||
style={{ color: colour, borderColor: `color-mix(in srgb, ${colour} 45%, transparent)`, background: `color-mix(in srgb, ${colour} 14%, transparent)` }}
|
||||
>
|
||||
{mode}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeDot({ mode }: { mode: string }) {
|
||||
const colour = MODE_COLOUR[mode];
|
||||
if (!colour) return null;
|
||||
@@ -599,11 +619,27 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
value={tpIdx}
|
||||
onChange={(e) => setTpIdx(Number(e.target.value))}
|
||||
>
|
||||
{/* The mode belongs in the choice itself. A satellite with an FM
|
||||
repeater and a linear transponder offers two labels that both
|
||||
read like a name, and picking the wrong one is a whole pass
|
||||
spent on the wrong kind of radio. */}
|
||||
{bird!.transponders!.map((x, i) => (
|
||||
<option key={i} value={i}>{x.label}</option>
|
||||
<option key={i} value={i}>
|
||||
{x.label} — {x.mode}{x.ctcss ? ` ${x.ctcss.toFixed(1)}` : ''}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
{/* Also in the header, so the mode and the tone survive hiding the
|
||||
readout column — which is exactly what an operator does when they
|
||||
want the map full width during a pass. */}
|
||||
<ModeBadge mode={tp?.mode} />
|
||||
{tp?.mode === 'FM' && !!tp.ctcss && (
|
||||
<span className="shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold tabular-nums border border-caution/45 bg-caution/10 text-caution"
|
||||
title={t('sat.toneHint')}>
|
||||
{tp.ctcss.toFixed(1)}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tracking?.on ? 'default' : 'outline'}
|
||||
@@ -684,7 +720,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
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>
|
||||
<span className="text-[11px] text-muted-foreground truncate">{tp?.label ?? ''}</span>
|
||||
<ModeBadge mode={tp?.mode} className="self-center" />
|
||||
</div>
|
||||
|
||||
{bird?.geostationary ? (
|
||||
@@ -783,17 +819,52 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
|
||||
|
||||
{/* What to tune. */}
|
||||
<div className="rounded-lg border border-border bg-card p-2">
|
||||
{/* The mode leads, because it decides everything below it. */}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<FreqRow label={t('sat.down')} hz={tuning?.down_hz ?? 0} nominal={tuning?.nominal_down ?? 0} />
|
||||
<FreqRow label={t('sat.up')} hz={tuning?.up_hz ?? 0} nominal={tuning?.nominal_up ?? 0} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-muted-foreground">
|
||||
{!!tp?.mode && <span>{tp.mode}</span>}
|
||||
{!!tp?.ctcss && <span>CTCSS {tp.ctcss.toFixed(1)}</span>}
|
||||
{tp?.inverting && <span>{t('sat.inverting')}</span>}
|
||||
{tp?.linear && <span>{Math.round((tp.down_hi - tp.down_lo) / 1000)} kHz</span>}
|
||||
{bird?.geostationary && <span>{t('sat.geo')}</span>}
|
||||
</div>
|
||||
|
||||
{/* The tone, on the FM birds, with the same weight as a frequency.
|
||||
It IS one, as far as the outcome goes: a repeater called without
|
||||
its tone does not answer, and the operator hears an empty
|
||||
channel and concludes the satellite is not up. Said explicitly
|
||||
when there is none, too — a blank line cannot tell "no tone"
|
||||
from "OpsLog does not know". */}
|
||||
{tp?.mode === 'FM' && (
|
||||
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2">
|
||||
<span className="w-10 text-[10px] uppercase tracking-wide text-muted-foreground">
|
||||
{t('sat.tone')}
|
||||
</span>
|
||||
{tp.ctcss ? (
|
||||
<>
|
||||
<span className="text-base font-semibold tabular-nums text-caution">
|
||||
{tp.ctcss.toFixed(1)} Hz
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">{t('sat.toneHint')}</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-[11px] text-muted-foreground">{t('sat.toneNone')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* What is coming. */}
|
||||
|
||||
@@ -604,6 +604,7 @@ const en: Dict = {
|
||||
'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.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.tipAos': 'Rises', 'sat.tipLos': 'Sets', 'sat.tipMaxEl': 'Peak',
|
||||
'sat.tipBelow': 'below the horizon', 'sat.tipNoPass': 'no pass in the prediction window',
|
||||
'sat.approaching': 'approaching', 'sat.receding': 'receding', 'sat.below': 'below the horizon',
|
||||
@@ -1218,6 +1219,7 @@ const fr: Dict = {
|
||||
'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.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.tipAos': 'Lever', 'sat.tipLos': 'Coucher', 'sat.tipMaxEl': 'Culmination',
|
||||
'sat.tipBelow': 'sous l’horizon', 'sat.tipNoPass': 'aucun passage dans la fenêtre de prévision',
|
||||
'sat.approaching': 'se rapproche', 'sat.receding': 's’éloigne', 'sat.below': 'sous l’horizon',
|
||||
|
||||
Reference in New Issue
Block a user