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,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": [
|
||||||
|
"L’onglet 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 l’autre 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 qu’il faut accorder, la liste des passes. Chacun retient s’il 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 n’est pas ce que le plan de fréquences ou n’importe qui sur l’air appelle la fréquence du satellite ; elle a sa place à côté de Tracking, là où se trouve la radio, et c’est là qu’elle 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 d’une passe sont des pastilles aux couleurs d’état de l’application, 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 n’est pas une couleur exploitable par un canvas : le navigateur conservait ce qu’il avait et le tracé sortait dans un blanc pâle qui disparaissait aussi bien sur l’imagerie que sur les déserts. C’est 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": "",
|
||||||
|
|||||||
@@ -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 ?? '—'}
|
||||||
</div>
|
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 ? (
|
{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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user