Compare commits

...
7 Commits
Author SHA1 Message Date
rouggy d0d29659cb fix(maps): the ground track stops cutting straight across the map
splitAtAntimeridian works from which COPY of the world each longitude is
in, so it needs a series that runs past ±180 rather than jumping back.
greatCirclePoints produces one; a satellite ground track does not — SGP4
reports every longitude inside (−180, 180]. A track leaving Kamchatka at
+179.9 and arriving in Alaska at −179.9 therefore looked like a single
359.8° step inside one world, no split was made, and the polyline drew
the chord: a straight dashed line clean across the map on every crossing.

The series is now unwrapped first. A jump of more than 180° between
consecutive points is that wrap and nothing else — no real path steps
half the globe between samples — and the unwrap is idempotent, so the
great-circle caller is unaffected.

Checked on three series: a normalised crossing now yields two segments
meeting exactly at ±180 where it used to yield one chord; an already
continuous 170→183 still splits at 180; a track that never crosses comes
back untouched.
2026-09-10 17:48:39 +02:00
rouggy 6f9b996db8 fix(sat): both antennas on both satellite slices
A slice has an rxant and a txant, and each pair belongs to that slice's
own band. Only two of the four were being set — the downlink's receive
antenna and the uplink's transmit one — so the downlink slice was left
with an empty txant. It never keys, so nothing was wrong on the air, but
the slice was half-configured: move transmit focus to it and the radio
uses whatever antenna it happened to be left on. Reported with XVTA on
2 m and XVTB on 70 cm, where the 70 cm slice showed one antenna and the
2 m slice showed two.

SatAntennas now takes all four, and the late-slice replay in
adoptSatSlice hands the uplink its own pair instead of putting the
transmit antenna on both of its ports.
2026-09-10 17:48:28 +02:00
rouggy 9dcab0568b docs(changelog): entries cut to one or two sentences
CLAUDE.md says one or two sentences with the rationale in the commit,
and I had been writing paragraphs: 0.27.23 ran to about 7 700 characters
over fifteen entries and 0.27.24 to 3 500 over eight. Release notes
nobody can scroll through are release notes nobody reads.

Same subjects, same order, outcome only — 1 950 and 955 characters.
Every "because", every measured number and every internal mechanism is
already in the commit that made the change, which is where they belong.
2026-09-10 15:43:53 +02:00
rouggy a8e870e098 fix(update): the relaunched OpsLog was starting invisibly
The relaunch after an update called hideConsole on the command that
starts the new build. That sets SysProcAttr{HideWindow: true}, which on
Windows becomes SW_HIDE in the STARTUPINFO handed to CreateProcess — and
Windows applies it to the first top-level window the new process shows.
So the updated OpsLog started correctly, took the single-instance mutex,
opened the logbook and connected the rig, and never appeared.

That is the report, in full: a process in the task manager, no window,
no autostart programs, and ending it then launching OpsLog by hand
working every time. Two operators, both on 0.27.23.

Why 0.27.19 did not fix it: that commit fixed the other half of the same
symptom — the new instance being less patient than the old one is slow —
which was real and is still fixed. The window was never part of it.

Where it came from: removing the PowerShell helper. Start-Process
launched the exe with a normal show; the direct exec.Command that
replaced it borrowed hideConsole from the console tools next to it,
where hiding a console window is exactly right and where the three
remaining callers (tasklist, taskkill, the deferred-swap PowerShell)
still belong.

The proof it was this and not the waiting: OpsLog relaunches itself in
two places, and RestartApp — the database switch — was byte-identical
except that it never called hideConsole. It has never been reported
broken. Both now go through relaunchCmd, so the rule lives in one place
with the reason written down, rather than in two call sites that differed
by one line.

Two tests: relaunchCmd leaves SysProcAttr nil, and update.go does not
call hideConsole at all.
2026-09-10 15:40:02 +02:00
rouggy 7e6e1335e3 fix(sat,decodes): the left column holds position too, and drift stops lying
Two things.

The sky plot and the position share the left column now, and the button
that opens it says so the way the right one does — it was a radar icon,
left over from when it toggled a plot inside the readout column, and the
gesture is the same one on both sides. The plot and the numbers are the
same answer at two precisions: azimuth and elevation drawn, then written
out to the digit. Having them at opposite ends of the window meant
reading a bearing off one side and finding it on the other.

And the band-drift warning, reported by W4TE. It compared the decoder's
announced band against RigState.Band, which is the TRANSMIT band — so
with slice A on 20 m running its own WSJT-X, slice B on 40 m, and
transmit focus on B, the 20 m decoder was told the rig was on 40 m while
the slice it listens to had been on 20 m throughout. The panel's own
comment had accepted this as a line that setup could read past; it is
worse than that, because the warning names a band and asserts something
false about the radio.

RigState now carries RxBands: every band the rig has a receiver on. One
entry on a single-VFO rig, one per slice on a Flex, the transmit band
always included so it cannot come back empty while the rig is on a
frequency. The warning fires only when the decoder announces a band
NOTHING on the radio is on, which is what it was always for and what a
lost CAT link actually looks like.
2026-09-10 15:25:54 +02:00
rouggy 3fe14c2c77 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.
2026-09-10 15:09:27 +02:00
rouggy e9816241e8 docs(changelog): 0.27.23 describes what ships, not how it was built
Eighteen entries down to fifteen. Three subjects were written as they
were built — announced, then corrected, then corrected again — and a
block is read top to bottom by an operator who saw none of the states in
between. The reverse FT-map layer was the worst of it: one entry
promised dashed cyan arcs, the next said the arcs were gone, a third
filled the marks and gave them a colour. Nobody outside this machine
ever had the version being corrected.

- The wind fix and the blinking fix are one change to one test: strict
  on the way into movement, loose on the way out.
- The who-hears-me layer, its diamonds and its colour are one feature.
- The control layout says where things are, without the detour through
  where they were first put.

Also: the decode colour no longer claims to sit next to the basemap
buttons, which is where it was for two commits.
2026-09-10 14:54:01 +02:00
16 changed files with 738 additions and 205 deletions
+1 -2
View File
@@ -3103,8 +3103,7 @@ func (a *App) RestartApp() error {
// --wait-pid, so it waits for this process to actually END rather than for a // --wait-pid, so it waits for this process to actually END rather than for a
// window of time to pass: shutting down closes a logbook and a CAT session, // window of time to pass: shutting down closes a logbook and a CAT session,
// and on a remote database that takes as long as it takes. // and on a remote database that takes as long as it takes.
cmd := exec.Command(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid())) cmd := relaunchCmd(exe, "--relaunch", "--wait-pid", strconv.Itoa(os.Getpid()))
cmd.Dir = filepath.Dir(exe)
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("relaunch OpsLog: %w", err) return fmt.Errorf("relaunch OpsLog: %w", err)
} }
+11 -7
View File
@@ -697,21 +697,25 @@ func (a *App) applySatRadio(tp sat.Transponder) {
if err != nil || len(m) == 0 { if err != nil || len(m) == 0 {
return return
} }
// The downlink is received, so it takes that band's RX antenna; the uplink // Each slice gets BOTH antennas of ITS OWN band. Only two of the four were
// is transmitted, so it takes that band's TX antenna. // set — the downlink's receive and the uplink's transmit — which left the
// downlink slice with no txant. It never keys, so nothing was wrong on the
// air, but a half-configured slice uses whatever antenna it was last left
// on the moment transmit focus moves to it.
downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo) downBand, upBand := flexBandAntKey(tp.DownLo), flexBandAntKey(tp.UpLo)
rxAnt := m[downBand].RX down, up := m[downBand], m[upBand]
txAnt := m[upBand].TX if strings.TrimSpace(down.RX) == "" && strings.TrimSpace(down.TX) == "" &&
if strings.TrimSpace(rxAnt) == "" && strings.TrimSpace(txAnt) == "" { strings.TrimSpace(up.RX) == "" && strings.TrimSpace(up.TX) == "" {
// Worth a line: an operator who HAS configured the pair and still sees // Worth a line: an operator who HAS configured the pair and still sees
// the wrong antenna has no other way to tell a setting he never made // the wrong antenna has no other way to tell a setting he never made
// from a lookup that missed. // from a lookup that missed.
applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand) applog.Printf("sat: no antenna configured for this pass (down %s, up %s)", downBand, upBand)
return return
} }
applog.Printf("sat: antennas rx=%q (%s) tx=%q (%s)", rxAnt, downBand, txAnt, upBand) applog.Printf("sat: antennas down %s rx=%q tx=%q, up %s rx=%q tx=%q",
downBand, down.RX, down.TX, upBand, up.RX, up.TX)
if err := a.cat.FlexDo(func(fc cat.FlexController) error { if err := a.cat.FlexDo(func(fc cat.FlexController) error {
return fc.SatAntennas(rxAnt, txAnt) return fc.SatAntennas(down.RX, down.TX, up.RX, up.TX)
}); err != nil { }); err != nil {
// Not fatal: a rig that is not a Flex has no such thing, and a pass with // Not fatal: a rig that is not a Flex has no such thing, and a pass with
// the wrong antenna is still a pass. // the wrong antenna is still a pass.
+55 -36
View File
@@ -1,46 +1,65 @@
[ [
{
"version": "0.27.24",
"en": [
"The sky plot and the position readout move to their own column, left of the map. Width is draggable, and the column folds away like the one on the right.",
"Each block of the right-hand column now has a heading and folds away on its own, remembered between sessions.",
"The tune panel shows the centre frequency of the transponder. The Doppler-corrected one stays beside Tracking, where the radio is.",
"Badges on the satellite panel for the band, the Doppler offset, an inverting transponder, the passband width and a pass's peak elevation.",
"The satellite ground track is visible on every basemap — it was drawn in a colour the map could not use and came out near-white.",
"The decode panel no longer warns about band drift on a second slice: it now compares against every band the radio is receiving on, not just the transmit band.",
"OpsLog appears again after an update. The relaunch was starting the new build with its window hidden, so it ran with no window at all.",
"Each satellite slice now gets both of its antennas, RX and TX, from its own band — the downlink slice was left with no transmit antenna at all.",
"The satellite ground track no longer draws a straight line across the map when it crosses the antimeridian."
],
"fr": [
"Le tracé du ciel et la position passent dans leur propre colonne, à gauche de la carte. Largeur réglable, et la colonne se replie comme celle de droite.",
"Chaque bloc de la colonne de droite a désormais un titre et se replie séparément, avec son état retenu.",
"Le panneau daccord affiche la fréquence centrale du transpondeur. La valeur corrigée du Doppler reste à côté de Tracking, là où est la radio.",
"Des pastilles sur le panneau satellite pour la bande, l’écart Doppler, un transpondeur inverseur, la largeur de bande passante et le pic d’élévation dune passe.",
"Le tracé au sol des satellites est visible sur tous les fonds de carte — il était dessiné dans une couleur inexploitable et sortait presque blanc.",
"Le panneau de décodages navertit plus dune dérive de bande sur une seconde tranche : la comparaison porte sur toutes les bandes reçues, pas seulement celle d’émission.",
"OpsLog réapparaît après une mise à jour. La relance démarrait la nouvelle version avec sa fenêtre masquée, donc sans aucune fenêtre.",
"Chaque slice satellite reçoit désormais ses deux antennes, RX et TX, depuis sa propre bande — la slice de descente restait sans antenne d’émission.",
"Le tracé au sol du satellite ne trace plus une ligne droite en travers de la carte lorsquil franchit lantiméridien."
]
},
{ {
"version": "0.27.23", "version": "0.27.23",
"date": "", "date": "",
"en": [ "en": [
"SteppIR: a Calibrate button, beside Retract. It drives every element to its end stop so the controller re-learns where zero is — the cure for an antenna that tunes to the wrong length after a power cut mid-move, after the elements were pushed by hand, or after a motor slipped. It takes minutes and the antenna is unusable until it finishes, so it asks first. Ultrabeam controllers have no such command and say so rather than pretending. Retracting the elements was already there and now explains itself: it is the storage position, and the next tune brings them back out on its own.", "SteppIR: a Calibrate button beside Retract, on Station Control.",
"On the rotor dial, the beam no longer whips a full turn round the compass when the antenna crosses north. A rotator at 020° turned anticlockwise reports 020, 010, 000, 359, 358 … and the animation travelled from 20° to 359° the long way — a complete revolution on screen while the mast moved forty degrees the other way. The beam now follows an accumulated angle, so what is drawn is the way the antenna is actually turning. Both lobes of a bidirectional antenna are handled separately, since they cross north at different moments.", "On the rotor dial, the beam no longer takes the long way round when it crosses north on a 450° rotator.",
"The rotor widget stops claiming the antenna is turning every time the wind moves it. A threshold alone could not tell the difference — a gust pushes a beam well past four degrees and back, and each excursion counted, so Stop lit and went out all evening on an antenna that had not turned. What separates a rotation from the weather is not how far the reading moves but which way: a rotor under power advances, gust after gust reverses. A step now counts only when the previous one went the same way, so movement is announced one poll later on a mast that takes half a minute to cross a pass, and never at all on a windy afternoon.", "The rotor widget stops lighting Stop when the wind moves the antenna, and stops going dark while it is genuinely turning.",
"LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. It was shipped with nothing but an APRS digipeater, because the transponder database marks that transponder inactive: it runs to an announced schedule rather than continuously, which from a database looks exactly like a dead one. The label says \"(scheduled)\" so nobody wonders why it is quiet. The generator now also lists the satellites it refused on that ground and kept nothing else for, so the next regeneration is not silent about them.", "LilacSat-2 gets its FM transponder — 144.350 up, 437.200 down — and its proper name, LO-90. The label says \"(scheduled)\": it runs to an announced schedule, not continuously.",
"A frequency OpsLog got wrong can now be corrected on a station that already has the satellite file. Until now the file was copied out on the first run and was the operator's from then on, so a mistake we shipped — LilacSat-2 with no FM transponder — had become their data and could never be mended. The plan now adds what is missing, brings up to date what was never edited, and leaves alone what was: an entry you corrected by hand outranks anything shipped, and the log names the ones it stood down on. The first run after this cannot tell the two apart, so it takes the shipped plan and copies your file to satellites.json.bak first — after that your edits are recognised precisely and survive every release.", "A frequency OpsLog shipped wrong can now be corrected on a station that already has the satellite file. Entries you edited by hand are left alone.",
"The per-band antennas are applied to the satellite slices at last. The map is keyed by the band name in capitals, the tracker looked its two bands up in lower case, and so it read no antenna at all out of a table the operator had filled in — a 70 cm downlink stayed on the 2 m transverter. The log now names the band and the antenna it resolved, so a setting never made can be told apart from a lookup that missed.", "The per-band FlexRadio antennas are applied to the satellite slices. A 70 cm downlink was staying on the 2 m transverter.",
"Tracking now shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude, in the same strip as the two frequencies. The elevation goes dim below the horizon, so a bird still being followed on its way up cannot be read as workable.", "Tracking shows where the satellite is beside where the antenna points: azimuth, elevation, distance and altitude.",
"The two satellite lists in the settings are sorted by name, numerically — AO-27, AO-91, AO-123. The left one came in the order the frequency file happens to be written and the right one in the order they were clicked, so finding one bird among sixteen meant reading all sixteen. A satellite renamed on getting its OSCAR number, as LILACSAT-2 became LO-90, is also recognised in a followed list that still holds the old name instead of being listed as having no elements.", "The two satellite lists in the settings are sorted by name, numerically. A satellite renamed on getting its OSCAR number is recognised under its old name too.",
"The grid-square map filters by one mode, by band and by satellite. The FTx button is gone: it grouped FT8, FT4 and FT2 together, while the question the map answers is where a single mode has been heard. The named modes are read from the log itself, so each one is offered only if there is something behind it — and FT2 appears for whoever is already using it, with nothing to change here the day it becomes a registered mode. The band list is the station's own plus anything worked outside it, and the satellite list only appears once a square has been worked through a bird.", "The grid-square map filters by one mode, by band and by satellite. The mode and satellite lists come from the log itself.",
"Saving the satellite settings now refreshes the Satellites tab. It read the followed list once when it was opened, so un-following a satellite left it in the pass table and in the dropdown until the tab was reopened — which looks exactly like a setting that failed to save. The lowest pass, the window and the locator are picked up the same way, and a satellite that is no longer followed hands the selection to the first one that is.", "Saving the satellite settings refreshes the Satellites tab un-following a satellite left it in the pass table until the tab was reopened.",
"The FT map takes one colour for every decode, next to the basemap buttons. The band palette is calibrated against a plain map — 60m navy and 70cm olive all but vanish over the satellite imagery — and it is only useful to somebody watching several bands at once, which most operators are not. Leave it empty for the colour per band, as before. The legend then keeps the band names and drops the swatches, since which bands are up is still worth knowing.", "The FT map takes one colour for every decode, from the corner above it. Leave it empty for the colour per band.",
"\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. It only hid the card before, and the check behind it runs every five minutes, so the same notice came back four times an hour all evening for a version already declined. The delay is recorded against that version, so a newer release is different news and appears at once — a deferral can never bury an update. The cross hides it for an hour, and opening About still answers whatever was deferred, because asking is asking.", "\"Later\" on an update notice now asks for how long: 1, 4, 12 or 24 hours. A newer release still appears at once.",
"Stop stays lit for the whole rotation instead of blinking. Keeping the wind out took a four-degree step, but four degrees is four seconds of travel on a real rotor, so between two of them nothing confirmed the mast was still turning and the button went dark and came back the whole way round. Getting in stays strict — two steps the same way, which weather never gives — while staying in now needs only continued progress in that direction: one degree the same way is not a gust when the rotor is already under power.", "The satellite frequency plan is checked against AMSAT's live FM and image lists: AO-123 gets its 67.0 Hz tone, SO-50 notes the 74.4 Hz arming tone, and eight SSTV satellites are added.",
"The frequency plan is checked against AMSAT's live FM and image lists. AO-123 gets the 67.0 Hz tone its uplink needs, SO-50 says in its label that 74.4 Hz arms the timer before 67.0 can key it, and eight SSTV satellites are added — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S and RS83S — as receive-only entries, with SSTV downlinks added to SONATE-2 and QMR-KWT-2. Every FM bird's uplink, downlink and tone now agrees with the published table.", "The FT map can show who hears YOU — the stations reporting your transmissions to PSK Reporter, as filled diamonds in a colour of their own. Off until asked for.",
"The FT map can now show who hears YOU. \"Who hears me\" draws, in dashed cyan, every station reporting your own transmissions to PSK Reporter, alongside the solid arcs of what you decode — the direction you cannot see from your own receiver, and on FT8 the one that says whether calling is worth the cycle. It costs almost nothing: your callsign goes in the topic's transmit level, so the broker sends nothing else, and it works with the band-opening watch switched off. Off until asked for, and each station appears once with its freshest report inside a fifteen-minute window.", "The FT map's controls no longer cover it: basemaps clear of the zoom on the left, colour and \"Who hears me\" on the right."
"\"Who hears me\" is marks alone, no arcs. With a few dozen receivers reporting, a line to each made a fan out of one square that buried the decode arcs beside it — and the lines said nothing, since every one of these paths starts in the same place. The mark is a hollow diamond, fading with age: shape rather than colour carries the difference, the decode dots being filled circles and the arcs having used fourteen colours already.",
"The FT map's controls stop covering it. The basemap buttons had crept over Leaflet's zoom control, so the minus button took clicks meant for Street, and the row had grown with the colour picker and the reverse layer until it reached the middle of a world map — covering the Atlantic to save a top-right corner that was empty the whole time. Basemaps stay on the left, clear of the zoom; the colour and \"Who hears me\" move to the right.",
"The who-hears-me diamonds are filled and take a colour of their own, chosen beside the switch that turns them on. Filled with a hairline white edge, the trick the home marker already uses: a ring is an outline drawn over whatever is beneath it, and eight pixels of it over the satellite imagery was barely there. The colour is separate from the decode one because a single control would have forced both layers into one colour — the very distinction it took a shape to make."
], ],
"fr": [ "fr": [
"SteppIR : un bouton Calibrer, à côté de Rétracter. Il amène chaque élément en butée pour que le contrôleur retrouve son zéro — le remède à une antenne qui saccorde à la mauvaise longueur après une coupure en pleine course, après avoir poussé les éléments à la main, ou après un moteur qui a glissé. Cela prend plusieurs minutes et lantenne est inutilisable jusqu’à la fin : une confirmation est donc demandée. Les contrôleurs Ultrabeam nont pas cette commande et le disent, au lieu de faire semblant. Rétracter les éléments existait déjà et sexplique maintenant : cest la position de rangement, et le prochain accord les fait ressortir tout seuls.", "SteppIR : un bouton Calibrer à côté de Rétracter, sur Station Control.",
"Sur le cadran du rotor, le faisceau ne fait plus un tour complet de la boussole quand lantenne franchit le nord. Un rotor à 020° tourné dans le sens antihoraire annonce 020, 010, 000, 359, 358… et lanimation allait de 20° à 359° par le chemin long — une révolution complète à l’écran pendant que le pylône bougeait de quarante degrés dans lautre sens. Le faisceau suit désormais un angle cumulé : ce qui est dessiné est le mouvement réel de lantenne. Les deux lobes dune antenne bidirectionnelle sont traités séparément, puisquils ne franchissent pas le nord au même moment.", "Sur le cadran du rotor, le faisceau ne fait plus le tour complet en passant le nord sur un rotor 450°.",
"Le widget rotor nannonce plus une antenne en rotation chaque fois que le vent la bouge. Un simple seuil ne pouvait pas faire la différence — une rafale pousse une beam bien au-delà de quatre degrés puis la ramène, et chaque écart comptait : Stop sallumait et s’éteignait toute la soirée sur une antenne qui navait pas tourné. Ce qui distingue une rotation de la météo nest pas lamplitude mais le sens : un rotor sous tension avance, une rafale revient. Un écart ne compte donc que si le précédent allait dans le même sens — la rotation est annoncée un relevé plus tard sur un pylône qui met trente secondes à traverser, et plus du tout par vent fort.", "Le widget rotor nallume plus Stop quand le vent bouge lantenne, et ne s’éteint plus pendant quelle tourne vraiment.",
"LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Il était livré avec un simple digipeater APRS, parce que la base de transpondeurs marque ce transpondeur comme inactif : il fonctionne selon un calendrier annoncé plutôt quen continu, ce qui, vu dune base de données, ressemble exactement à un transpondeur mort. Le libellé indique « (scheduled) » pour que personne ne se demande pourquoi il est muet. Le générateur liste désormais aussi les satellites quil a écartés pour cette raison sans rien garder dautre, afin que la prochaine régénération ne les passe plus sous silence.", "LilacSat-2 récupère son transpondeur FM — 144,350 en montée, 437,200 en descente — et son vrai nom, LO-90. Le libellé indique « (scheduled) » : il fonctionne selon un calendrier annoncé, pas en continu.",
"Une fréquence quOpsLog avait fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Jusquici ce fichier était copié au premier lancement puis appartenait à lopérateur, si bien quune erreur de notre part — LilacSat-2 sans transpondeur FM — devenait sa donnée et ne pouvait plus être réparée. Le plan ajoute maintenant ce qui manque, met à jour ce qui na jamais été modifié, et laisse intact ce qui la été : une entrée corrigée à la main prime sur tout ce qui est livré, et le journal nomme celles devant lesquelles il sest effacé. Le premier lancement après ce changement ne peut pas distinguer les deux : il prend donc le plan livré et copie dabord votre fichier en satellites.json.bak — ensuite vos modifications sont reconnues précisément et survivent à chaque version.", "Une fréquence livrée fausse peut désormais être corrigée sur une station qui possède déjà le fichier satellites. Les entrées modifiées à la main sont préservées.",
"Les antennes par bande sont enfin appliquées aux slices satellite. La table est indexée par le nom de bande en majuscules, le suivi cherchait ses deux bandes en minuscules, et il ne lisait donc aucune antenne dans un tableau que lopérateur avait rempli — une descente 70 cm restait sur le transverter 2 m. Le journal indique désormais la bande et lantenne retenues, pour distinguer un réglage jamais fait dune recherche qui a échoué.", "Les antennes FlexRadio par bande sont appliquées aux slices satellite. Une descente 70 cm restait sur le transverter 2 m.",
"Le suivi affiche désormais où est le satellite à côté de là où pointe lantenne : azimut, élévation, distance et altitude, dans le même bandeau que les deux fréquences. L’élévation sestompe sous lhorizon, pour quun satellite encore suivi avant son lever ne soit pas pris pour un satellite travaillable.", "Le suivi affiche où est le satellite à côté de là où pointe lantenne : azimut, élévation, distance et altitude.",
"Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres — AO-27, AO-91, AO-123. Celle de gauche suivait lordre du fichier de fréquences et celle de droite lordre des clics : retrouver un satellite parmi seize obligeait à lire les seize. Un satellite renommé lors de lattribution de son numéro OSCAR, comme LILACSAT-2 devenu LO-90, est aussi reconnu dans une liste de suivis qui porte encore lancien nom, au lieu dy apparaître sans éléments.", "Les deux listes de satellites des réglages sont triées par nom, en tenant compte des nombres. Un satellite renommé lors de lattribution de son numéro OSCAR est aussi reconnu sous son ancien nom.",
"La carte des carrés locator se filtre par mode précis, par bande et par satellite. Le bouton FTx disparaît : il regroupait FT8, FT4 et FT2, alors que la question à laquelle répond cette carte est de savoir où un seul mode a été entendu. Les modes proposés sont lus dans le journal, donc chacun napparaît que sil y a quelque chose derrière — et FT2 est proposé à qui lutilise déjà, sans rien à changer ici le jour où il deviendra un mode officiel. La liste des bandes est celle de la station, complétée par ce qui a été travaillé en dehors, et la liste des satellites napparaît quune fois un carré travaillé par satellite.", "La carte des carrés locator se filtre par mode précis, par bande et par satellite. Les listes de modes et de satellites viennent du journal.",
"Enregistrer les réglages satellite rafraîchit désormais longlet Satellites. Il lisait la liste des suivis une seule fois à son ouverture : retirer un satellite le laissait dans le tableau des passes et dans la liste déroulante jusqu’à la réouverture de longlet — ce qui ressemble exactement à un réglage qui ne sest pas enregistré. L’élévation minimale, la fenêtre de prévision et le locator sont pris en compte de la même façon, et un satellite qui nest plus suivi passe la sélection au premier qui lest.", "Enregistrer les réglages satellite rafraîchit longlet Satellites — retirer un satellite le laissait dans le tableau des passes jusqu’à la réouverture.",
"La carte FT accepte une couleur unique pour tous les décodages, à côté des boutons de fond de carte. La palette par bande est calibrée sur une carte claire — le bleu marine du 60 m et lolive du 70 cm disparaissent presque sur limagerie satellite — et elle ne sert qu’à qui surveille plusieurs bandes à la fois, ce qui nest pas le cas de la plupart. Laissez-la vide pour retrouver la couleur par bande. La légende conserve alors les noms de bandes et abandonne les pastilles, car savoir quelles bandes sont ouvertes reste utile.", "La carte FT accepte une couleur unique pour tous les décodages, depuis le coin en haut à droite. Laissez vide pour la couleur par bande.",
"« Plus tard » sur une notification de mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Le bouton ne faisait que masquer la fenêtre, et la vérification derrière tourne toutes les cinq minutes : la même notification revenait quatre fois par heure toute la soirée pour une version déjà refusée. Le report est enregistré pour cette version précise, donc une version plus récente est une autre nouvelle et saffiche immédiatement — un report ne peut jamais enterrer une mise à jour. La croix masque pendant une heure, et ouvrir « À propos » répond toujours, même sur une version reportée : demander, cest demander.", "« Plus tard » sur une mise à jour demande désormais combien de temps : 1, 4, 12 ou 24 heures. Une version plus récente saffiche quand même aussitôt.",
"Le bouton Stop reste allumé pendant toute la rotation au lieu de clignoter. Écarter le vent demandait un écart de quatre degrés, mais quatre degrés représentent quatre secondes de course sur un vrai rotor : entre deux dentre eux, plus rien ne confirmait que le pylône tournait encore et le bouton s’éteignait puis se rallumait tout du long. Lentrée reste stricte — deux écarts dans le même sens, ce que la météo ne produit jamais — tandis que le maintien ne demande plus quune progression continue dans ce sens : un degré du même côté nest pas une rafale quand le rotor est déjà sous tension.", "Le plan de fréquences satellite est vérifié contre les listes FM et image dAMSAT : AO-123 reçoit son ton de 67,0 Hz, SO-50 signale le ton darmement de 74,4 Hz, et huit satellites SSTV sont ajoutés.",
"Le plan de fréquences est vérifié contre les listes FM et image dAMSAT. AO-123 reçoit le ton de 67,0 Hz quexige sa montée, SO-50 indique dans son libellé que 74,4 Hz arme le minuteur avant que 67,0 puisse louvrir, et huit satellites SSTV sont ajoutés — HC1PX, RS18S, RS27S, RS38S, RS40S, RS57S, RS58S et RS83S — en réception seule, avec une descente SSTV ajoutée à SONATE-2 et QMR-KWT-2. La montée, la descente et le ton de chaque satellite FM correspondent désormais à la table publiée.", "La carte FT peut montrer qui VOUS entend — les stations qui rapportent vos émissions à PSK Reporter, en losanges pleins dune couleur propre. Éteint par défaut.",
"La carte FT peut désormais montrer qui VOUS entend. « Qui mentend » trace en pointillés cyan chaque station qui rapporte vos propres émissions à PSK Reporter, à côté des arcs pleins de ce que vous décodez — la direction quon ne peut pas voir depuis son propre récepteur, et en FT8 celle qui dit si appeler vaut le cycle. Le coût est quasi nul : votre indicatif est placé dans le niveau émission du topic, donc le broker nenvoie rien dautre, et cela fonctionne même avec la veille douverture de bande éteinte. Éteint par défaut, et chaque station apparaît une fois avec son rapport le plus récent dans une fenêtre de quinze minutes.", "Les contrôles de la carte FT ne la recouvrent plus : fonds de carte dégagés du zoom à gauche, couleur et « Qui mentend » à droite."
"« Qui mentend » naffiche plus que des marques, sans arcs. Avec quelques dizaines de récepteurs, un trait vers chacun formait un éventail depuis un seul carré qui enterrait les arcs de décodage voisins — et ces traits ne disaient rien, puisque tous ces chemins partent du même endroit. La marque est un losange creux qui sestompe avec l’âge : cest la forme, et non la couleur, qui porte la différence, les points de décodage étant des cercles pleins et les arcs utilisant déjà quatorze couleurs.",
"Les contrôles de la carte FT cessent de la recouvrir. Les boutons de fond de carte avaient débordé sur le zoom de Leaflet — le bouton moins prenait les clics destinés à Street — et la rangée s’était allongée avec le sélecteur de couleur et la couche inverse jusqu’à atteindre le milieu dune carte du monde, masquant lAtlantique pour épargner un coin haut-droit resté vide tout ce temps. Les fonds de carte restent à gauche, dégagés du zoom ; la couleur et « Qui mentend » passent à droite.",
"Les losanges de « qui mentend » sont pleins et prennent leur propre couleur, choisie à côté de linterrupteur qui les allume. Pleins avec un liseré blanc dun pixel, le procédé quutilise déjà le marqueur de la station : un cercle creux nest quun contour tracé sur ce qui se trouve dessous, et huit pixels de contour sur limagerie satellite se voyaient à peine. La couleur est distincte de celle des décodages, car un seul réglage aurait forcé les deux couches dans une même couleur — précisément la distinction quil a fallu une forme pour établir."
] ]
}, },
{ {
+3
View File
@@ -6644,6 +6644,9 @@ export default function App() {
// Only while CAT is actually connected: an empty band means "nothing to // Only while CAT is actually connected: an empty band means "nothing to
// compare with", never "the rig is on no band". // compare with", never "the rig is on no band".
rigBand={catState.connected ? (catState.band || '') : ''} rigBand={catState.connected ? (catState.band || '') : ''}
// Every band the radio is listening on, so a second slice on a second
// band does not read as a decoder that has lost CAT.
rigBands={catState.connected ? (catState.rx_bands ?? []) : []}
myCall={station.callsign} myCall={station.callsign}
myGrid={station.my_grid} myGrid={station.my_grid}
// A DOUBLE click answers the station: it hands the decode back to // A DOUBLE click answers the station: it hands the decode back to
+16 -3
View File
@@ -102,6 +102,9 @@ interface Props {
// The band the RIG is on, when CAT is connected. Only ever compared with what // The band the RIG is on, when CAT is connected. Only ever compared with what
// the decoder announces — see the drift warning. // the decoder announces — see the drift warning.
rigBand?: string; rigBand?: string;
// Every band the radio has a receiver on. A Flex running two slices has
// two, and a decoder on either of them is not drifting.
rigBands?: string[];
onCall: (d: Decode) => void; onCall: (d: Decode) => void;
// A single click: take the station without transmitting — fill the entry, and // A single click: take the station without transmitting — fill the entry, and
// point the panels at it. Absent, a click falls back to onCall. // point the panels at it. Absent, a click falls back to onCall.
@@ -641,7 +644,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) { export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, rigBands, onCall, onSelect, myCall, myGrid, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) {
const { t } = useI18n(); const { t } = useI18n();
// Column widths, dragged in the header and shared by every row. Persisted // Column widths, dragged in the header and shared by every row. Persisted
// through writeUiPref (not raw localStorage) so the layout travels with data/ // through writeUiPref (not raw localStorage) so the layout travels with data/
@@ -778,9 +781,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// Said, not decided. Using the rig's band instead would be wrong for anyone // Said, not decided. Using the rig's band instead would be wrong for anyone
// decoding a second receiver on another band, and a warning costs that setup // decoding a second receiver on another band, and a warning costs that setup
// nothing but a line it can read past. // nothing but a line it can read past.
//
// Compared against every band the radio is RECEIVING on, not the transmit
// band. Two slices on two bands with a decoder on each is a normal setup,
// and it made this warning lie: with slice A on 20 m, slice B on 40 m and
// transmit focus on B, the 20 m decoder was told the rig was on 40 m while
// the slice it listens to was on 20 m all along. The warning is for a
// decoder announcing a band NOTHING on the radio is on, which is what a
// lost CAT link actually looks like.
const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : ''; const decoderBand = decodes.length ? (decodes[decodes.length - 1].band ?? '') : '';
const bandDrift = !!rigBand && !!decoderBand const onAir = (rigBands && rigBands.length ? rigBands : (rigBand ? [rigBand] : []))
&& rigBand.toLowerCase() !== decoderBand.toLowerCase(); .map((b) => b.toLowerCase());
const bandDrift = onAir.length > 0 && !!decoderBand
&& !onAir.includes(decoderBand.toLowerCase());
const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : ''; const driftInstance = decodes.length ? (decodes[decodes.length - 1].instance ?? '') : '';
const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode; const liveMode = decodes.length ? decodes[decodes.length - 1].mode : txState?.mode;
+340 -104
View File
@@ -1,7 +1,9 @@
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,
PanelLeftClose, PanelLeftOpen, 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 +75,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 +505,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 +633,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));
@@ -784,13 +858,18 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
> >
{Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)} {Object.entries(BASEMAPS).map(([k, v]) => <option key={k} value={k}>{v.label}</option>)}
</select> </select>
{/* The left column, with the same kind of control as the right one.
It was a Radar icon, from when this toggled a plot inside the
readout column. It now opens and shuts a column of the window, so
it says so the way the other one does — the two are the same
gesture and an operator should not have to learn them twice. */}
<Button <Button
variant="ghost" size="sm" variant="ghost" size="sm" className="h-7 px-1.5"
className={cn('h-7 px-1.5', skyShown && 'text-success')}
onClick={() => setSkyShown((v) => !v)} onClick={() => setSkyShown((v) => !v)}
title={skyShown ? t('sat.hideSky') : t('sat.showSky')} title={skyShown ? t('sat.hideSky') : t('sat.showSky')}
> >
<Radar className="size-3.5" /> {skyShown ? <PanelLeftClose className="size-3.5" /> : <PanelLeftOpen className="size-3.5" />}
</Button> </Button>
{/* Put the whole window on the map. On a laptop the readout takes a {/* Put the whole window on the map. On a laptop the readout takes a
third of the screen, and there are moments — watching a footprint third of the screen, and there are moments — watching a footprint
@@ -807,6 +886,108 @@ 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">
{/* WHERE IT IS: the sky plot and the position, in a column of their
own to the left of the map.
The plot is the sky seen from underneath — the centre is
straight up, the rim is the horizon, north is at the top — and
one glance says whether the pass comes over the roof or along
the treeline. The numbers below it are the same answer to the
digit: azimuth, elevation, distance, height. They belong
together, and having them apart meant reading a bearing off one
side of the window and finding it on the other.
Both were in the readout column, where a plot that wants to be
square competed for width with everything else 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 overflow-y-auto" 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>
{/* 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) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</Section>
</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 +1013,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,97 +1066,35 @@ 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
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">
<div className="grid grid-cols-2 gap-2">
<Readout label={t('sat.az')} value={tuning ? fmtDeg(tuning.az) : '—'}
sub={tuning ? compass(tuning.az) : ''} />
<Readout label={t('sat.el')} value={tuning ? fmtDeg(tuning.el) : '—'}
colour={tuning?.visible ? 'var(--success)' : 'var(--muted-foreground)'}
sub={tuning ? (tuning.visible ? t('sat.up') : t('sat.below')) : ''} />
</div>
<div className="mt-2 grid grid-cols-3 gap-1 text-[11px] tabular-nums">
<PassBit label={t('sat.range')} value={tuning?.range_km ? fmtKm(tuning.range_km) : '—'} />
<PassBit label={t('sat.altitude')} value={tuning?.alt_km ? fmtKm(tuning.alt_km) : '—'} />
<PassBit label={t('sat.footprint')} value={tuning?.footprint_km ? fmtKm(tuning.footprint_km) : '—'} />
</div>
{/* Approaching or receding, which is the sign of the whole Doppler
correction and the one number that explains why the frequencies
are moving the way they are. */}
{!!tuning && !bird?.geostationary && (
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground tabular-nums">
{tuning.range_rate < 0
? <ArrowUp className="size-3 text-success" />
: <ArrowDown className="size-3 text-warning" />}
<span>{tuning.range_rate < 0 ? t('sat.approaching') : t('sat.receding')}</span>
<span>{Math.abs(tuning.range_rate).toFixed(2)} km/s</span>
</div>
)}
{/* Where the antenna is, beside where the satellite is. The two
differing is a rotator still slewing; the two differing for a
long time is a rotator that is stuck, and that is worth being
able to see without walking outside. */}
{tracking?.rot_on && (
<div className="mt-1.5 pt-1.5 border-t border-border/60 flex items-baseline gap-2 text-[11px] tabular-nums">
<span className="text-muted-foreground uppercase tracking-wide text-[10px]">{t('sat.antenna')}</span>
{/* No elevation when none is being driven: an undriven zero
draws an antenna lying on the horizon, which is a bearing
and not the absence of one. */}
<span className="font-medium">
{tracking.rot_az_only
? fmtDeg(tracking.rot_az)
: `${fmtDeg(tracking.rot_az)} / ${fmtDeg(tracking.rot_el)}`}
</span>
{tracking.rot_az_only && <span className="text-muted-foreground">{t('sat.rotAzOnly')}</span>}
{!tracking.rot_live && <span className="text-muted-foreground">{t('sat.rotCommanded')}</span>}
</div>
)}
</div>
{/* 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 +1125,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 +1216,7 @@ export function SatellitePanel({ myGrid }: { myGrid: string }) {
</table> </table>
)} )}
</div> </div>
</div> </Section>
</div> </div>
</div> </div>
</div> </div>
@@ -1098,11 +1237,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 +1313,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>
); );
+8 -2
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',
@@ -622,7 +625,7 @@ const en: Dict = {
'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.', 'sat.sideWidthTip': 'Drag to resize the readout. Double-click to reset it.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In', 'sat.thSat': 'Satellite', 'sat.thAos': 'Rise', 'sat.thLos': 'Set', 'sat.thMaxEl': 'Max', 'sat.thIn': 'In',
'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top', 'sat.skyPlot': 'The pass across the sky — centre is straight up, the rim is the horizon, north is at the top',
'sat.showSky': 'Show the sky plot', 'sat.hideSky': 'Hide the sky plot', 'sat.showSky': 'Show the sky and position', 'sat.hideSky': 'Hide the sky and position',
'sec.satellite': 'Satellites', 'sec.satellite': 'Satellites',
'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.', 'satset.hint': 'Where the antenna is, and the machine that points it. The satellites you follow and the frequency plan are in the Satellites tab.',
'satset.gridPlaceholder': 'e.g. JN18cx', 'satset.gridPlaceholder': 'e.g. JN18cx',
@@ -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',
@@ -1245,7 +1251,7 @@ const fr: Dict = {
'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.', 'sat.sideWidthTip': 'Glisser pour redimensionner le panneau. Double-clic pour le remettre par défaut.',
'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans', 'sat.thSat': 'Satellite', 'sat.thAos': 'Lever', 'sat.thLos': 'Coucher', 'sat.thMaxEl': 'Max', 'sat.thIn': 'Dans',
'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est lhorizon, le nord en haut', 'sat.skyPlot': 'Le passage dans le ciel — le centre est à la verticale, le bord est lhorizon, le nord en haut',
'sat.showSky': 'Afficher la vue du ciel', 'sat.hideSky': 'Masquer la vue du ciel', 'sat.showSky': 'Afficher le ciel et la position', 'sat.hideSky': 'Masquer le ciel et la position',
'sec.satellite': 'Satellites', 'sec.satellite': 'Satellites',
'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.', 'satset.hint': 'Où se trouve lantenne, et la machine qui la pointe. Les satellites suivis et le plan de fréquences sont dans longlet Satellites.',
'satset.gridPlaceholder': 'ex. JN18cx', 'satset.gridPlaceholder': 'ex. JN18cx',
+32 -2
View File
@@ -186,8 +186,38 @@ export function greatCirclePoints(
// opposite edge, at the SAME latitude, so the line leaves one side of the map // opposite edge, at the SAME latitude, so the line leaves one side of the map
// and re-enters the other at the height it left. Leaflet takes the result as a // and re-enters the other at the height it left. Leaflet takes the result as a
// multi-polyline, so one path is still one layer. // multi-polyline, so one path is still one layer.
export function splitAtAntimeridian(pts: [number, number][]): [number, number][][] { // unwrapLon makes a longitude series continuous.
if (pts.length === 0) return []; //
// splitAtAntimeridian works from which COPY of the world each longitude is in,
// which requires the series to run past ±180 rather than jumping back. A great
// circle built by greatCirclePoints already does; a satellite ground track does
// not — SGP4 reports every longitude inside (180, 180], so a track leaving
// Kamchatka at +179.9 and arriving in Alaska at 179.9 looked to the splitter
// like one step of 359.8° inside a single world. No split was made and the
// polyline drew the chord: a straight dashed line clean across the map, from
// one side of the planet to the other, on every crossing.
//
// A jump of more than 180° between two consecutive points is that wrap and
// nothing else: no real path steps half the globe between samples. Idempotent
// on a series that was already continuous, so both callers can share it.
function unwrapLon(pts: [number, number][]): [number, number][] {
if (pts.length < 2) return pts;
const out: [number, number][] = [pts[0]];
let turns = 0;
for (let i = 1; i < pts.length; i++) {
const [lat, lon] = pts[i];
const prevRaw = pts[i - 1][1];
const d = lon - prevRaw;
if (d > 180) turns -= 1;
else if (d < -180) turns += 1;
out.push([lat, lon + 360 * turns]);
}
return out;
}
export function splitAtAntimeridian(input: [number, number][]): [number, number][][] {
if (input.length === 0) return [];
const pts = unwrapLon(input);
// Which copy of the world a longitude belongs to: 0 is the map's own. // Which copy of the world a longitude belongs to: 0 is the map's own.
const world = (lon: number) => Math.floor((lon + 180) / 360); const world = (lon: number) => Math.floor((lon + 180) / 360);
const norm = (lon: number) => lon - 360 * world(lon); const norm = (lon: number) => lon - 360 * world(lon);
+2
View File
@@ -1154,6 +1154,7 @@ export namespace cat {
split?: boolean; split?: boolean;
mode?: string; mode?: string;
band?: string; band?: string;
rx_bands?: string[];
vfo?: string; vfo?: string;
error?: string; error?: string;
// Go type: time // Go type: time
@@ -1175,6 +1176,7 @@ export namespace cat {
this.split = source["split"]; this.split = source["split"];
this.mode = source["mode"]; this.mode = source["mode"];
this.band = source["band"]; this.band = source["band"];
this.rx_bands = source["rx_bands"];
this.vfo = source["vfo"]; this.vfo = source["vfo"];
this.error = source["error"]; this.error = source["error"];
this.updated_at = this.convertValues(source["updated_at"], null); this.updated_at = this.convertValues(source["updated_at"], null);
+60 -14
View File
@@ -50,18 +50,28 @@ type interruptible interface {
// and RxFreqHz is the active VFO (where they listen). When not split, // and RxFreqHz is the active VFO (where they listen). When not split,
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field. // RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
type RigState struct { type RigState struct {
Enabled bool `json:"enabled"` // user toggled CAT on Enabled bool `json:"enabled"` // user toggled CAT on
Connected bool `json:"connected"` // backend says rig is online Connected bool `json:"connected"` // backend says rig is online
Backend string `json:"backend,omitempty"` // active backend name Backend string `json:"backend,omitempty"` // active backend name
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable) RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
Rig string `json:"rig,omitempty"` // rig model (best-effort) Rig string `json:"rig,omitempty"` // rig model (best-effort)
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split) FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
Split bool `json:"split,omitempty"` // rig is in split mode Split bool `json:"split,omitempty"` // rig is in split mode
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY) Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
Band string `json:"band,omitempty"` // computed from FreqHz Band string `json:"band,omitempty"` // computed from FreqHz
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB" // RxBands is every band the radio currently has a receiver on, Band
Error string `json:"error,omitempty"` // last connect/poll error if any // included. One entry on a rig with one VFO; one per slice on a Flex.
//
// It exists because Band alone is the TRANSMIT band, and a station running
// two slices on two bands with a decoder on each is not drifting when one
// of them announces the band it is legitimately on. Reported for W4TE:
// slice A on 20 m with its own WSJT-X, slice B on 40 m holding transmit
// focus, and OpsLog telling him his 20 m decoder disagreed with a radio
// that was on 20 m — on that slice.
RxBands []string `json:"rx_bands,omitempty"`
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
Error string `json:"error,omitempty"` // last connect/poll error if any
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
@@ -584,9 +594,9 @@ type FlexController interface {
SetMute(bool) error SetMute(bool) error
SetRXAntenna(string) error SetRXAntenna(string) error
SetTXAntenna(string) error SetTXAntenna(string) error
// SatAntennas sets the antenna on each SATELLITE slice — they are on two // SatAntennas sets BOTH antennas on each SATELLITE slice — they are on two
// different bands and, with transverters, two different ports. // different bands and, with transverters, two different ports.
SatAntennas(rxAnt, txAnt string) error SatAntennas(downRX, downTX, upRX, upTX string) error
// SatTone sets the CTCSS tone the satellite uplink transmits (0 = off). // SatTone sets the CTCSS tone the satellite uplink transmits (0 = off).
SatTone(hz float64) error SatTone(hz float64) error
SetActiveSlice(int) error // focus slice idx so commands target it SetActiveSlice(int) error // focus slice idx so commands target it
@@ -1002,11 +1012,47 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
if ns.FreqHz != 0 && ns.Band == "" { if ns.FreqHz != 0 && ns.Band == "" {
ns.Band = BandFromHz(ns.FreqHz) ns.Band = BandFromHz(ns.FreqHz)
} }
ns.RxBands = m.rxBands(ns)
m.update(ns) m.update(ns)
} }
} }
} }
// rxBands lists the bands the radio has a receiver on.
//
// Asked of the backend rather than derived from the state, because only the
// backend knows: a Flex reports its slices, and everything else has exactly
// one receiver whose band is already in the state. The transmit band is
// always included, even on a Flex, so this can never come back empty while
// the rig is on a frequency.
func (m *Manager) rxBands(st RigState) []string {
seen := map[string]bool{}
var out []string
add := func(b string) {
if b = strings.ToLower(strings.TrimSpace(b)); b != "" && !seen[b] {
seen[b] = true
out = append(out, b)
}
}
add(st.Band)
if st.RxFreqHz != 0 {
add(BandFromHz(st.RxFreqHz))
}
if fx, ok := m.FlexState(); ok {
for _, sl := range fx.Slices {
if sl.Band != "" {
add(sl.Band)
continue
}
// A slice the radio has reported a frequency but no band for.
if sl.FreqHz != 0 {
add(BandFromHz(sl.FreqHz))
}
}
}
return out
}
func (m *Manager) applyCommandDelay() { func (m *Manager) applyCommandDelay() {
m.mu.RLock() m.mu.RLock()
d := m.cmdDelay d := m.cmdDelay
+7 -3
View File
@@ -79,9 +79,13 @@ type Flex struct {
// turns up LATE. Arming, the antennas, the tone and the mode all happen // turns up LATE. Arming, the antennas, the tone and the mode all happen
// before the radio has necessarily reported the slice it was asked to // before the radio has necessarily reported the slice it was asked to
// create; without this they were applied to an index of -1 and never again. // create; without this they were applied to an index of -1 and never again.
satUpMode string satUpMode string
satRXAnt string // Both antennas of both slices: a slice has an rxant and a txant, and each
satTXAnt string // pair belongs to that slice's own band.
satDownRX string
satDownTX string
satUpRX string
satUpTX string
satTone float64 satTone float64
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode) spotMode map[int]string // spot index → ADIF mode, so a click can also set the slice mode (SmartSDR tunes the spot's freq but not its mode)
+32 -21
View File
@@ -155,11 +155,13 @@ func (f *Flex) adoptSatSlice(role string, idx int) {
// antenna and the tone are all sent ONCE — the step only re-sends // antenna and the tone are all sent ONCE — the step only re-sends
// frequencies. // frequencies.
f.mu.Lock() f.mu.Lock()
mode, ant, tone := f.satUpMode, f.satTXAnt, f.satTone mode, rxAnt, txAnt, tone := f.satUpMode, f.satUpRX, f.satUpTX, f.satTone
f.mu.Unlock() f.mu.Unlock()
if strings.TrimSpace(ant) != "" { if strings.TrimSpace(rxAnt) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", idx, ant)) f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
f.send(fmt.Sprintf("slice s %d rxant=%s", idx, ant)) }
if strings.TrimSpace(txAnt) != "" {
f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
} }
if strings.TrimSpace(mode) != "" { if strings.TrimSpace(mode) != "" {
f.satMode(idx, mode, 0) f.satMode(idx, mode, 0)
@@ -259,7 +261,7 @@ func (f *Flex) SatReceiveHz() (int64, error) {
return s.freqHz, nil return s.freqHz, nil
} }
// SatAntennas selects the antenna each satellite slice uses. // SatAntennas selects both antennas of each satellite slice.
// //
// The two slices are on two different bands — a V/U bird receives on 70 cm and // The two slices are on two different bands — a V/U bird receives on 70 cm and
// transmits on 2 m, a U/V one does the reverse — so they cannot share one // transmits on 2 m, a U/V one does the reverse — so they cannot share one
@@ -274,30 +276,39 @@ func (f *Flex) SatReceiveHz() (int64, error) {
// Empty strings are left alone: an operator who has configured 2 m and not // Empty strings are left alone: an operator who has configured 2 m and not
// 70 cm should keep whatever the radio already had on the other side rather // 70 cm should keep whatever the radio already had on the other side rather
// than have it cleared. // than have it cleared.
func (f *Flex) SatAntennas(rxAnt, txAnt string) error { // A slice has an rxant AND a txant, and both belong to the slice's own band.
// Only two of the four were being set — the downlink's receive antenna and
// the uplink's transmit one — which left the downlink slice with an empty
// txant. It never keys, so nothing was wrong on the air, but the slice was
// half-configured: move transmit focus to it and the radio uses whatever
// antenna it was last left on.
func (f *Flex) SatAntennas(downRX, downTX, upRX, upTX string) error {
f.mu.Lock() f.mu.Lock()
rx, tx := f.satRX, f.satTX rx, tx := f.satRX, f.satTX
connected := f.conn != nil connected := f.conn != nil
// Remembered so a slice that is reported late still gets its antenna. // Remembered so a slice that is reported late still gets its antennas.
f.satRXAnt, f.satTXAnt = rxAnt, txAnt f.satDownRX, f.satDownTX = downRX, downTX
f.satUpRX, f.satUpTX = upRX, upTX
f.mu.Unlock() f.mu.Unlock()
if !connected { if !connected {
return fmt.Errorf("flex: not connected") return fmt.Errorf("flex: not connected")
} }
// The downlink slice is the one being listened to, so it takes the receive set := func(idx int, which, rxAnt, txAnt string) {
// antenna; the uplink slice is the one keyed, so it takes the transmit one. if idx < 0 {
if rx >= 0 && strings.TrimSpace(rxAnt) != "" { return
f.send(fmt.Sprintf("slice s %d rxant=%s", rx, rxAnt)) }
applog.Printf("flex: satellite downlink slice %d on antenna %s", rx, rxAnt) if strings.TrimSpace(rxAnt) != "" {
} f.send(fmt.Sprintf("slice s %d rxant=%s", idx, rxAnt))
if tx >= 0 && strings.TrimSpace(txAnt) != "" { }
f.send(fmt.Sprintf("slice s %d txant=%s", tx, txAnt)) if strings.TrimSpace(txAnt) != "" {
// A transmit slice also has to HEAR its own band on some radios, and a f.send(fmt.Sprintf("slice s %d txant=%s", idx, txAnt))
// transverter port is the only thing connected to it. Setting the }
// receive antenna to match costs nothing when it is already right. if strings.TrimSpace(rxAnt) != "" || strings.TrimSpace(txAnt) != "" {
f.send(fmt.Sprintf("slice s %d rxant=%s", tx, txAnt)) applog.Printf("flex: satellite %s slice %d rx=%s tx=%s", which, idx, rxAnt, txAnt)
applog.Printf("flex: satellite uplink slice %d on antenna %s", tx, txAnt) }
} }
set(rx, "downlink", downRX, downTX)
set(tx, "uplink", upRX, upTX)
return nil return nil
} }
+66
View File
@@ -0,0 +1,66 @@
package cat
import (
"strings"
"testing"
)
// RxBands is what stops the decode panel's band-drift warning from lying to a
// station running two slices.
//
// Reported for W4TE: slice A on 20 m with its own WSJT-X, slice B on 40 m
// holding transmit focus. RigState.Band is the TRANSMIT band, so the 20 m
// decoder was told "the rig is on 40M" while the slice it listens to had been
// on 20 m the whole time.
func TestRxBandsAlwaysCarriesTheTransmitBand(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m"})
if len(got) != 1 || got[0] != "20m" {
t.Fatalf("got %v, want [20m]", got)
}
}
// A split rig receives on one band and transmits on another — cross-band split
// is unusual but legal, and the receive side is where a decoder listens.
func TestRxBandsIncludesTheSplitReceiveBand(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20m", RxFreqHz: 7074000, Split: true})
if !has(got, "20m") || !has(got, "40m") {
t.Fatalf("got %v, want both 20m and 40m", got)
}
}
// Nothing to report when the rig is on no frequency: an empty list means "there
// is nothing to compare with", and the panel treats it as such rather than as
// "the radio is on no band", which would warn about every decode.
func TestRxBandsIsEmptyWithNoFrequency(t *testing.T) {
m := &Manager{}
if got := m.rxBands(RigState{}); len(got) != 0 {
t.Fatalf("got %v, want nothing", got)
}
}
// Deduplicated and lower-cased, because the panel compares strings: two slices
// on the same band are one band, and "20M" from a backend must match "20m" from
// BandFromHz.
func TestRxBandsIsNormalised(t *testing.T) {
m := &Manager{}
got := m.rxBands(RigState{FreqHz: 14074000, Band: "20M", RxFreqHz: 14080000})
if len(got) != 1 || got[0] != "20m" {
t.Fatalf("got %v, want [20m]", got)
}
for _, b := range got {
if b != strings.ToLower(b) {
t.Errorf("%q is not lower-cased", b)
}
}
}
func has(list []string, want string) bool {
for _, s := range list {
if s == want {
return true
}
}
return false
}
+37
View File
@@ -0,0 +1,37 @@
package main
import (
"os/exec"
"path/filepath"
)
// relaunchCmd builds the command that starts OpsLog again — after an update, or
// after a database switch.
//
// It exists to hold one fact in one place: a relaunch of OPSLOG ITSELF must not
// suppress the new process's window.
//
// The auto-update relaunch used to go through hideConsole, which sets
// SysProcAttr{HideWindow: true}. On Windows that puts SW_HIDE into the
// STARTUPINFO handed to CreateProcess, and Windows applies it to the first
// top-level window the new process shows. So the updated OpsLog started
// perfectly, took the single-instance mutex, connected the rig — and never
// became visible. Reported by two operators on 0.27.23 as "it goes to reload
// and just fails to load": a process in the task manager, no window, killing it
// and starting it by hand working every time.
//
// It arrived with the removal of the PowerShell helper. PowerShell's
// Start-Process launched the exe with a normal show, and the direct
// exec.Command that replaced it borrowed hideConsole from the console tools
// beside it — where hiding a console window is exactly right, and where every
// other caller still belongs. Two self-relaunches then differed by that one
// line, and only the hidden one was ever reported broken.
//
// So: no SysProcAttr at all, which is what RestartApp already did and why the
// database-switch relaunch never showed the fault. There is no console to
// suppress either way — OpsLog is linked for the Windows GUI subsystem.
func relaunchCmd(exe string, args ...string) *exec.Cmd {
cmd := exec.Command(exe, args...)
cmd.Dir = filepath.Dir(exe)
return cmd
}
+53
View File
@@ -0,0 +1,53 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A relaunch of OpsLog itself must not suppress the new process's window.
//
// SysProcAttr is where that damage was done: hideConsole sets HideWindow, which
// on Windows becomes SW_HIDE in the STARTUPINFO, and Windows applies it to the
// first top-level window the new process shows. The updated OpsLog started,
// took the single-instance mutex and connected the rig — invisibly. Two
// operators on 0.27.23 reported it as "it goes to reload and just fails to
// load": a process in the task manager, no window, and killing it then starting
// it by hand working every time.
//
// Nil, not "some specific value": there is nothing a self-relaunch needs from
// STARTUPINFO, and anything set there is a window flag waiting to be wrong.
func TestRelaunchCmdDoesNotTouchTheWindow(t *testing.T) {
cmd := relaunchCmd(filepath.Join("C:", "OpsLog", "OpsLog.exe"), "--post-update", "--wait-pid", "1234")
if cmd.SysProcAttr != nil {
t.Errorf("relaunchCmd set SysProcAttr = %+v; a self-relaunch must leave the window alone", cmd.SysProcAttr)
}
if len(cmd.Args) != 4 || cmd.Args[1] != "--post-update" || cmd.Args[3] != "1234" {
t.Errorf("args = %v, want the exe plus the three passed through", cmd.Args)
}
// The working directory matters: the new instance keeps its data folder
// beside the executable, and inheriting the old process's cwd would look for
// it somewhere else entirely.
if cmd.Dir != filepath.Join("C:", "OpsLog") {
t.Errorf("Dir = %q, want the executable's folder", cmd.Dir)
}
}
// hideConsole is right for the console tools and wrong for OpsLog, and the two
// live a few lines apart. This is the guard that stops the update path
// borrowing it again — which is how it broke the first time, when the
// PowerShell helper was replaced by a direct exec.Command beside them.
func TestUpdateRelaunchDoesNotHideTheWindow(t *testing.T) {
src, err := os.ReadFile("update.go")
if err != nil {
t.Fatalf("read update.go: %v", err)
}
if strings.Contains(string(src), "hideConsole(") {
t.Error("update.go calls hideConsole — a relaunch of OpsLog must not hide its window (see relaunch.go)")
}
if !strings.Contains(string(src), "relaunchCmd(exe,") {
t.Error("update.go no longer relaunches through relaunchCmd, where that rule is written down")
}
}
+15 -11
View File
@@ -7,7 +7,6 @@ import (
"io" "io"
"net/http" "net/http"
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -228,16 +227,21 @@ func (a *App) DownloadAndApplyUpdate(url string) error {
// side now. The new instance is told OUR pid and waits for this process to // side now. The new instance is told OUR pid and waits for this process to
// end before taking the single-instance mutex. // end before taking the single-instance mutex.
// //
// Waiting on the mutex alone was not enough, and that is the bug this line // Waiting on the mutex alone was not enough: shutting down is allowed thirty
// fixes: shutting down is allowed thirty seconds here (armExitWatchdog), // seconds here (armExitWatchdog), because it closes a remote logbook, a CAT
// because it closes a remote logbook, a CAT session and sometimes a backup, // session and sometimes a backup, while the new instance was only patient
// while the new instance was only patient for twenty. On a station where // for twenty. On a station where that ran long, the new process gave up and
// that ran long, the new process gave up and exited — leaving the old one // exited — leaving the old one still running and no new window, which is
// still running and no new window, which is precisely what the PowerShell // precisely what the PowerShell helper never did: it waited for the pid,
// helper never did: it waited for the pid, however long it took. // however long it took.
cmd := exec.Command(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid())) //
cmd.Dir = dir // And relaunchCmd rather than a command built here, because the OTHER half
hideConsole(cmd) // of the same report was this line calling hideConsole: SW_HIDE in the
// STARTUPINFO, which Windows applies to the new process's first window. The
// updated OpsLog started, took the mutex, and stayed invisible. See
// relaunch.go — the fact belongs in one place, since two self-relaunches
// differing by one line is how only one of them was broken.
cmd := relaunchCmd(exe, "--post-update", "--wait-pid", strconv.Itoa(os.Getpid()))
if err := cmd.Start(); err != nil { if err := cmd.Start(); err != nil {
return fmt.Errorf("schedule relaunch: %w", err) return fmt.Errorf("schedule relaunch: %w", err)
} }