fix(autocall): the QSO in progress outranks the ladder

Six faults from an evening on 60 m, all in the same family: the engine
judging a station by what the log wants from it and forgetting what is
already under way.

- An exchange was abandoned mid-QSO. The reply lands in the same period
  the ladder is re-read, and that period was judged before the reply was
  taken into account, so a better-ranked caller took the slot from a
  station that had just come back to us. The answer is settled first now,
  and our own report counts as being inside the exchange too — which also
  protects a QSO the operator started by hand.
- A station just picked started with misses against it. Its transmit slot
  was unknown until a second decode, and with the parity unknown every
  period counted, including the one spent transmitting to it.
- The freed slot after "it is working somebody else" was thrown away: the
  period's decodes are in hand, so the next station is picked from them
  rather than fifteen seconds later. Never mid-over.
- Auto-call is never armed from a stored setting — not at launch, not on
  a profile switch. It is the one feature that puts the station on the
  air by itself and OpsLog starts with Windows.
- It says what it is waiting for: a wanted station in a QSO with somebody
  else now shows beside the Auto button instead of looking idle.
- Switching profile left the previous logbook's verdicts on screen. The
  worked-index, chase-new and the frontend's cached verdicts are dropped
  when the logbook changes.

FT decodes: distance column, a message addressed to you set whole in
green (the station you are calling keeps a tint — most of what it sends
goes to other people), badge order L / Wkd / WL, list cleared when the
RIG changes band.

Rotor: new world-map compass from EC1KD's design, with the Ultrabeam boom
and second lobe restored and the compact form preserved; the classic dial
is kept and Settings → Rotator chooses between them. Stop no longer
flickers on a rotor standing still — movement was inferred from a degree,
less than the jitter a controller reports at rest.
This commit is contained in:
2026-09-05 23:02:31 +02:00
parent 8dbc4b7e62
commit 23323c91e0
14 changed files with 1752 additions and 389 deletions
+19 -3
View File
@@ -2603,7 +2603,20 @@ func (a *App) switchLogbook(p profile.Profile) error {
_ = old.Close() _ = old.Close()
} }
applog.Printf("logbook switched to %s for profile %q", backend, p.Name) applog.Printf("logbook switched to %s for profile %q", backend, p.Name)
// EVERY worked/new verdict belonged to the OLD logbook.
//
// The award matrices above are only half of it: the in-memory worked-index
// the alert engine reads, the chase-new list, and the frontend's own cache of
// resolved spot verdicts all still hold the previous log's answers. An
// operator switching to an empty profile and back found every decode on the
// screen badged NEW — the empty log's verdicts, cached under the full one,
// and nothing short of a restart cleared them.
if a.chaseNew != nil {
a.chaseNew.clear()
}
go a.rebuildWorkedIndex()
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
wruntime.EventsEmit(a.ctx, "logbook:changed") wruntime.EventsEmit(a.ctx, "logbook:changed")
} }
return nil return nil
@@ -16704,10 +16717,13 @@ func (a *App) reloadAfterProfileSwitch() {
// Auto-call is per profile — attempts, the "call only" callsign, whether it // Auto-call is per profile — attempts, the "call only" callsign, whether it
// is on at all — and it TRANSMITS. A switch that left the previous profile's // is on at all — and it TRANSMITS. A switch that left the previous profile's
// settings running would have the wrong station calling on the wrong log. // settings running would have the wrong station calling on the wrong log.
//
// Off, exactly as at launch: a switch changes the callsign, the log and
// usually the antenna, and none of that is a state in which a transmitter
// should resume by itself because this profile's stored setting said on.
// Re-applied rather than restarted: the loop is one goroutine for the life // Re-applied rather than restarted: the loop is one goroutine for the life
// of the process, and applyAutoCall clears the target when the feature is // of the process.
// off in the profile just activated. a.disarmAutoCall("profile switch")
a.applyAutoCall()
a.autoCallEngine().Reset() a.autoCallEngine().Reset()
} }
+26 -6
View File
@@ -197,6 +197,8 @@ type AutoCallStatus struct {
// whichever is typed into, both show it. // whichever is typed into, both show it.
Only string `json:"only"` Only string `json:"only"`
Target string `json:"target"` Target string `json:"target"`
// Waiting: what it would call if that station were not in a QSO.
Waiting string `json:"waiting"`
Calls int `json:"calls"` Calls int `json:"calls"`
Max int `json:"max"` Max int `json:"max"`
Misses int `json:"misses"` Misses int `json:"misses"`
@@ -218,7 +220,7 @@ func (a *App) GetAutoCallStatus() AutoCallStatus {
set := a.GetAutoCallSettings() set := a.GetAutoCallSettings()
return AutoCallStatus{ return AutoCallStatus{
Enabled: set.Enabled, Only: set.Only, Enabled: set.Enabled, Only: set.Only,
Target: st.Target, Calls: st.Attempts, Max: st.Max, Target: st.Target, Waiting: st.Waiting, Calls: st.Attempts, Max: st.Max,
Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped, Misses: st.Misses, MaxMiss: st.MaxMiss, Stopped: st.Stopped,
Greylisted: a.autoCallEngine().Greylisted(), Greylisted: a.autoCallEngine().Greylisted(),
Reason: reason, Reason: reason,
@@ -588,16 +590,34 @@ func (a *App) autoCallWatched(call string) bool {
return ok return ok
} }
// startAutoCall arms the engine at launch. // startAutoCall starts the engine's loop, with auto-call OFF.
// //
// The stored "on" is honoured, and the engine starts with NO target: a program // It is never on from a stored value. This is the one setting in the program
// that came up already calling a station chosen before the last shutdown is not // that puts the station on the air by itself, and the operator who left it on
// something an operator can be expected to anticipate. // last night is not necessarily the one at the desk now — nor necessarily at
// the desk at all: OpsLog starts with Windows, and a rig that powers up with it
// would begin calling into an empty shack, on whatever band the radio happens
// to be on, hours after anybody decided that was a good idea.
//
// Arming it is one click, and it is a click somebody has to make.
func (a *App) startAutoCall() { func (a *App) startAutoCall() {
a.applyAutoCall() a.disarmAutoCall("launch")
go a.autoCallLoop() go a.autoCallLoop()
} }
// disarmAutoCall switches auto-call off and writes that down.
//
// The setting is what the toolbar and the settings panel both read, so turning
// the engine off without storing it would show a lit switch over a silent
// transmitter — and the operator's next click, meaning "on", would send "off".
func (a *App) disarmAutoCall(why string) {
if a.settingOr(keyAutoCallOn, "0") == "1" {
applog.Printf("autocall: off at %s — it is never armed from a stored setting", why)
}
a.setSetting(keyAutoCallOn, "0")
a.applyAutoCall()
}
func (a *App) autoCallLoop() { func (a *App) autoCallLoop() {
// A quarter of a second. The sweeper is what closes a period, so its tick is // A quarter of a second. The sweeper is what closes a period, so its tick is
// part of the same budget as acQuiet: a two-second tick added up to two // part of the same budget as acQuiet: a two-second tick added up to two
+42 -54
View File
@@ -3,62 +3,50 @@
"version": "0.27.13", "version": "0.27.13",
"date": "", "date": "",
"en": [ "en": [
"Chase new: the panel now says what the FEED is doing — up or down, how many reports it has taken and how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all, which is the first question when it looks dead.", "Chase new: a band selection of its own, under the option (160 m to 70 cm). The stations band list still applies underneath — this one says what is worth WATCHING tonight.",
"Chase new: the count in the header counted what SURVIVED the category filters, so a panel holding twelve stations with the filters off read “0 heard”. It now shows both — “3 of 12 heard” — and the footer takes the radius from the feed instead of claiming ~300 km whatever the setting says.", "Chase new: the panel says what the feed is doing — up or down, how many reports it has taken, how many receiver squares it is subscribed to. An empty list said nothing about whether anything was arriving at all.",
"Chase new: category filters that hide everything are no longer remembered as a preference. A stored set holding none of the categories on offer — what a preference written by an older build becomes once a category is renamed — hid the whole panel at every launch with nothing to say why.", "Chase new fixes: the header counts both what the filters show and what the panel holds (“3 of 12 heard”), a stored filter set that hides everything is ignored, and switching between “new” and “new + unconfirmed” re-reads the list instead of leaving old verdicts on screen.",
"Chase new: a band selection of its own, under the option (160 m to 70 cm). The stations band list still applies underneath — this one says what is worth WATCHING tonight, which is a different question: a station equipped for 2 m and 70 cm that is chasing HF no longer has them in the list. A band switched off is dropped as it arrives, so it stops taking a place.", "PSK Reporter panel fixes: the history is asked for in both feed scopes and refreshed every five minutes, so a target picked shortly after start-up no longer shows an empty page; the suggested transmit offset always has an answer when there is data; and the texts say ten minutes, which is the window actually used.",
"Auto-call cut your own 73 in half. When the DXs RRR was decoded the QSO read as finished and the next station was picked in the same instant — but a reply is not queued: WSJT-X acts on it at once, drops the exchange and starts calling the new station. Nothing is called over a transmission in progress now, and the four-minute backstop restarts when the station begins answering, so a QSO that started after a long wait cannot be halted mid-exchange.", "Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the bands default.",
"Auto-call: a real need now outranks an unconfirmed one at the same level. Hunting “new + unconfirmed”, a band already worked and awaiting a QSL is flagged as needed too — rightly — but it is not the same catch as a band never worked, and the two ranked identically: with one of each in a period the engine took whichever was louder. An unconfirmed need drops below the real one at its own level and no further: an unconfirmed new band still outranks a real new slot.", "FT decodes: a receiver column appears when more than one decoder feeds the merged list, and the list empties for a receiver that changes band — half a screen of stations no longer reachable is worse than an empty one.",
"PSK Reporter panel: the history query now runs in BOTH feed scopes and is asked again every five minutes while a station is watched. It ran once, and only on the narrow feed — so a target picked shortly after opening the panel showed one decode where another program, subscribed for an hour, showed four. Uploaders batch their reports about every five minutes, which is the cadence this now matches.", "FT decodes: a pink WL badge marks a station on your watch list, and the period clock turns red while transmitting. It is the one thing on the screen that moves, so “am I on the air” is readable from across the room.",
"PSK Reporter panel: the texts said “in the last 5 min” while the window had become ten. The window is what changed; the sentences had not.", "Auto-call: a round of fixes from on-air use. It no longer cuts your own 73 short, calls four to six seconds late, starts a call and drops it a second later, keeps calling a station that has begun working somebody else, ignores a station calling you, misreads MSHVs two-answers-in-one-line, or refuses nearly everything on the air because it read the entitys “worked” as the stations.",
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-Xs DXpedition transmit modes, not modes of their own, and the comment fell through to the band plan and came out DATA — which then decided the band+mode verdict, so a ZD8 on 21.071 read as a new DATA slot rather than the new FT8 one it is.", "Auto-call: the priority order is stricter. A watched callsign outranks everything not on the list, a real need beats an unconfirmed one at the same level, and a better station may take over from one that has not answered yet — but never from a QSO in progress: once the station has come back to you, or you have sent it a report, nothing takes its place.",
"FT decodes: a receiver column appears when more than one decoder feeds one merged list. Two decoders on the same band send the same stations twice with their own SNR, and nothing on the row said which was which.", "Auto-call calls only what the decodes list is SHOWING: the filters above it — CQ only, LoTW only, the category chips, continents, minimum report, search — steer the transmitter as well as the eye. The separate “LoTW users only” option is gone.",
"Auto-call answered four to six seconds late, so the decoder started its call well inside the slot. A period was judged a whole slot plus four seconds after the DECODES OWN TIMESTAMP — which is the start of that slot, thirteen seconds before its decodes exist. It is now judged 0.8 s after the last decode ARRIVES, and the sweeper ticks four times a second instead of once every two: the answer leaves in the same second the decodes landed, which is what the next slot boundary requires.", "Auto-call: Halt is now a verdict on the station being called — set aside for the session, never called again until auto-call is switched off and on. It can also log every decision (Settings → DXHunter): one line per period saying what was on the air and why each station was refused.",
"FT decodes: the period clock turns red while transmitting. It is the one thing on the screen that moves, so it is where the eye already is, and “am I on the air” is worth reading from across the room.", "Auto-call says what it is waiting for. A wanted station that is working somebody else now shows next to the Auto button instead of leaving the panel looking idle.",
"Auto-call started a call and cut it a second later, then went again. Two faults, both visible in the log: the attempts cap trips WHILE the seventh call is going out — that is what counts it — and it halted the transmitter where it stood, sending half a call; and the station was then picked again four seconds later, because the rest after a series was skipped whenever nothing better was on the air, which for the station everybody is chasing is every period. The cap now lets the over finish before stopping, and a rest is a rest.", "FT decodes: a message addressed to YOU is set in green, whole and bold, with a green edge on the row — read from the far side of the shack. The station you are calling keeps a light tint and its callsign picked out in red: most of what it sends goes to other people, and colouring those lines the same way said you were in a QSO you were not in.",
"Chase new now follows a change of hunt (“new” ↔ “new + unconfirmed”). It judges each decode as it arrives and keeps only what was new at that moment, so its list was a set of verdicts reached under the old rule and nothing in it was ever re-judged: switching to “new only” left rows that are merely unconfirmed, and switching the other way could not bring back what had already been refused. The list is emptied on the change and refills from the live feed within a period or two.", "FT decodes: a distance column, next to the square it is computed from and in your own unit (km or miles). Rounded to whole units — a four-character grid is a square tens of kilometres wide and nothing finer is honest.",
"Halt is now a verdict on the station being auto-called: it is set aside for the session and never called again until auto-call is switched off and on. Halt used to clear the engines whole state — rests and rounds along with it — so the station the operator had just stopped was eligible again in the same second and the next period called it straight back. Naming it in the chase list does not override that either.", "Switching profile no longer leaves the previous logbooks verdicts on the screen. Coming back from a profile with an empty log, every decode and spot stayed badged NEW until a restart: the worked-index, the chase-new list and the cached verdicts are now all dropped when the logbook changes.",
"Auto-call can log every decision (Settings → DXHunter, under auto-call): one line per period saying what was on the air, how many stations were callable, why each of the others was refused — worked, nothing needed, mid-QSO, resting, halted, parked, no LoTW — and which ones were ranked. It answers the question the log could not: why nothing was called while the panel showed a screen full of wanted stations. A line every fifteen seconds, so it is off by default.", "Auto-call is always OFF at launch, and off again after a profile switch — never armed from a stored setting. It is the one thing in OpsLog that puts the station on the air by itself, and OpsLog starts with Windows: arming it is a click somebody has to make.",
"FT decodes: the list empties for a receiver that changes band. What is on 20 m says nothing about 40 m, and half a screen of stations no longer reachable is worse than an empty one — the period headings still march on and the badges still read as verdicts. Per receiver: in a split view the other one has not moved.", "Auto-call takes the freed slot at once when the station it was calling turns out to be working somebody else. It used to stop and wait a period before choosing again, so a CQ two rows down went unanswered for fifteen seconds — unless we are mid-transmission, where it still waits for the over to finish.",
"FT decodes: a pink WL badge marks a station on your watch list, first in the status column. It is your own answer to “is this one worth my time”, and it outranks whatever the log makes of the entity. Matched by the watch lists own rule — a trailing * is a prefix — so a badge and an alert cannot disagree about the same station.", "Rotor widget redrawn: a night world map behind a square azimuth scale, an orange pointer following the mouse so a click lands where it is aimed, a yellow marker on the azimuth ordered until the antenna gets there, and quick turns in columns of six. The Ultrabeam boom and its second lobe are still drawn, and Station Control keeps the plain dial — dial only, since anything under it is pushed off the bottom of the row. Design contributed by EC1KD — thank you.",
"Auto-call: a station CALLING YOU is answered whether or not the log has anything to gain. It was refused when nothing was needed from it — so a QSO would end, two stations would call on the next sequence, and the machine ignored both. Already worked on this band and mode, or stopped by the operator, still means no.", "Rotor widget: the Stop button no longer flickers on a rotor standing still. Movement was inferred from a one-degree change, which is less than the jitter a controller reports at rest.",
"Auto-call: a watched callsign now outranks every station that is not on the list, instead of being lifted one rung at a time. A watched DXpedition with nothing left to gain from it sat at the bottom of the ladder and was never reached on a band full of stations that were needed — which is the opposite of what putting it on the list meant. Among watched stations the old order still applies: entity, band, mode, slot.", "Settings → Rotator now chooses the dial: the new world-map compass or the classic one. Both are kept — one reads across the shack, the other is the compact dial that was there before — and the choice applies to the docked widget and to Station Control at once.",
"FT decodes: the watch-list badge comes after the LoTW “L”, which is one letter and always in the same place — a badge in front of it shifted the whole status column from row to row.", "Auto-call: a station just picked no longer starts with misses against it. The counter did not know which slot the station transmits in until it had been decoded a second time, so every period counted — including the one spent transmitting to it, where it cannot be heard at all."
"Auto-call stops calling a station that starts working somebody else. Callability was tested when the station was CHOSEN and never again while it was held, so one picked on its CQ that then answered another caller went on being called for the whole seven attempts — two minutes of transmitting at a station in a QSO that cannot hear it. Its final frame to another station does not count: that one is a period from being free, which is the best moment there is to be calling it. Released with no verdict attached, so it is fair game with a full allowance the moment it CQs again.",
"Auto-call now calls only what the decodes list is SHOWING. The filters above it — CQ only, LoTW only, the new-category chips, continents, minimum report, the search box — steer the transmitter as well as the eye: a station filtered off the screen is not called. The panel sends the callsigns it is showing rather than its filter settings, so there is one definition of “shown” and the engine cannot disagree with what is in front of the operator. With the list closed, nothing is filtered.",
"PSK Reporter panel: the suggested call offset now always has an answer when there is data. It looked for a clear run of empty slots and said nothing when it found none — which is exactly the case it was built for: a hundred decodes across a 2800 Hz passband leave no gap at all, and the panel drew the whole histogram with no advice under it. Failing a real gap it now names the quietest slot, ties going to the higher offset, and never above the ceiling.",
"Auto-call refused nearly every station on the air as “already worked”. It was reading the ENTITYs verdict as the stations: “worked” there means the COUNTRY is in the log on this band and mode, so on a band where the operator has most of them, everything decoding was turned away — a trace of one evening shows twenty decodes out of twenty-one refused that way, the watched DXpedition among them. The entitys verdict now decides what is NEEDED, and the callsigns own slot decides whether calling it would be a duplicate.",
"Auto-call reads a multi-answer line whole. MSHV answers two stations in one transmission and the decoder prints them as one line — “YV5ALI RR73; F4BPO <HK0/PY8WW> -08” — where the second half is a report to us. Reading only the start of it, the engine saw a station working somebody else and dropped the target at the exact moment the DX was answering. The decodes panel already read every segment; the engine now does too, for “is it calling us”, “can it be called” and “is the QSO finished”.",
"Auto-call leaves the slot after a QSO to your own 73. The DXs RR73 is decoded while you are between overs, so the decoder is about to send that 73 in the slot just opening — answering somebody else there switched the DX call mid-sequence, and the frame that closes the contact never went out whole. The next station is picked on the following period instead, fifteen seconds later, and it is still there. It used to hand straight on because the slot is the resource; the slot is worth less than the QSO it spoils."
], ],
"fr": [ "fr": [
"Chasse au nouveau : le panneau dit désormais ce que fait le FLUX — actif ou coupé, combien de reports il a pris, sur combien de carrés récepteurs il est abonné. Une liste vide ne disait rien sur le fait quil arrive quoi que ce soit, ce qui est la première question quand elle semble morte.", "Chase new : sélection de bandes propre au panneau, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous — celle-ci dit ce quon veut SURVEILLER ce soir.",
"Chasse au nouveau : le compteur de len-tête comptait ce qui SURVIVAIT aux filtres de catégorie — un panneau contenant douze stations affichait « 0 entendus » filtres éteints. Il affiche maintenant les deux (« 3 sur 12 entendus »), et le pied de panneau prend le rayon dans le flux au lieu dannoncer ~300 km quel que soit le réglage.", "Chase new : le panneau indique l’état du flux — connecté ou non, nombre de rapports reçus, nombre de carrés de réception souscrits. Une liste vide ne disait rien sur ce qui arrivait vraiment.",
"Chasse au nouveau : un filtrage qui masque tout nest plus retenu comme une préférence. Un ensemble enregistré ne contenant aucune des catégories proposées — ce que devient une préférence écrite par une version antérieure après le renommage dune catégorie — masquait tout le panneau à chaque lancement, sans rien dire.", "Chase new, corrections : len-tête compte à la fois ce que les filtres montrent et ce que le panneau contient (« 3 sur 12 entendues »), un jeu de filtres enregistré qui cache tout est ignoré, et le passage entre « new » et « new + non confirmés » relit la liste au lieu de laisser danciens verdicts à l’écran.",
"Chasse au nouveau : une sélection de bandes qui lui est propre, sous loption (160 m à 70 cm). La liste de bandes de la station sapplique toujours en dessous — celle-ci dit ce quon veut SURVEILLER ce soir, ce qui est une autre question : une station équipée 2 m et 70 cm qui chasse en HF ne les a plus dans la liste. Une bande décochée est écartée à larrivée, elle cesse de prendre une place.", "Panneau PSK Reporter, corrections : lhistorique est demandé dans les deux portées du flux et rafraîchi toutes les cinq minutes, donc une cible choisie peu après le démarrage naffiche plus une page vide ; loffset d’émission suggéré donne toujours une réponse quand il y a des données ; et les textes annoncent dix minutes, la fenêtre réellement utilisée.",
"Lappel automatique coupait votre propre 73. Au décodage du RRR du DX, le QSO était considéré terminé et la station suivante choisie dans le même instant — or un reply nest pas mis en file : WSJT-X lexécute aussitôt, abandonne l’échange et se met à appeler la nouvelle station. Plus rien nest appelé par-dessus une émission en cours, et le butoir de quatre minutes repart quand la station commence à répondre : un QSO entamé après une longue attente ne peut plus être interrompu en plein échange.", "Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpédition de WSJT-X, pas des modes en soi, et le commentaire retombait sur le mode par défaut de la bande.",
"Appel automatique : un vrai besoin passe désormais devant un besoin non confirmé de même niveau. En chasse « nouveau + non confirmé », une bande déjà travaillée en attente de QSL est signalée comme besoin — à juste titre — mais ce nest pas la même prise quune bande jamais travaillée, et les deux avaient le même rang : avec une de chaque dans la période, le moteur prenait la plus forte. Un besoin non confirmé descend sous le vrai besoin de son propre niveau, et pas plus bas : une nouvelle bande non confirmée passe toujours devant un vrai nouveau slot.", "FT decodes : une colonne récepteur apparaît quand plusieurs décodeurs alimentent la liste fusionnée, et la liste se vide pour un récepteur qui change de bande — un demi-écran de stations devenues inaccessibles est pire quun écran vide.",
"Panneau PSK Reporter : la requête dhistorique sexécute désormais dans LES DEUX portées de flux, et elle est refaite toutes les cinq minutes tant quune station est surveillée. Elle navait lieu quune fois, et seulement sur le flux étroit — une cible choisie peu après louverture du panneau affichait un décodage là où un autre programme, abonné depuis une heure, en montrait quatre. Les stations qui alimentent PSK Reporter envoient par paquets toutes les cinq minutes environ : cest cette cadence qui est désormais suivie.", "FT decodes : un badge WL rose marque une station de votre watchlist, et lhorloge de période passe au rouge en émission. Cest le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
"Panneau PSK Reporter : les textes annonçaient « depuis 5 min » alors que la fenêtre était passée à dix. Cest la fenêtre qui avait changé, pas les phrases.", "Auto-call : série de corrections issues de lutilisation en trafic. Il ne coupe plus votre 73, nappelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, ninsiste plus sur une station qui sest mise à travailler quelquun dautre, ne laisse plus sans réponse une station qui vous appelle, lit correctement les doubles réponses MSHV sur une même ligne, et ne refuse plus presque tout ce qui passe parce quil prenait le « worked » de lentité pour celui de la station.",
"Cluster : « superfox », « super fox », « fox/hound » et « F/H » dans un commentaire de spot sont lus comme du FT8. Ce sont les modes d’émission DXpedition de WSJT-X, pas des modes à part : le commentaire retombait sur le plan de bande et ressortait en DATA — ce qui décidait ensuite du verdict bande+mode, si bien quun ZD8 sur 21.071 se lisait comme un nouveau slot DATA au lieu du nouveau FT8 quil est.", "Auto-call : ordre de priorité plus strict. Un indicatif en watchlist passe devant tout ce qui ny est pas, un vrai besoin passe devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place dune autre qui na pas encore répondu — mais jamais celle dun QSO en cours : dès que la station vous a répondu, ou que vous lui avez envoyé un report, rien ne la remplace.",
"Décodages FT : une colonne récepteur apparaît quand plusieurs décodeurs alimentent une même liste fusionnée. Deux décodeurs sur la même bande envoient les mêmes stations deux fois avec leur propre SNR, et rien sur la ligne ne disait laquelle était laquelle.", "Auto-call nappelle que ce que la liste des décodes AFFICHE : les filtres du dessus — CQ seul, LoTW seul, les pastilles de catégorie, les continents, le rapport minimum, la recherche — pilotent l’émission autant que l’œil. Loption distincte « utilisateurs LoTW uniquement » disparaît.",
"Lappel automatique répondait avec quatre à six secondes de retard, si bien que le décodeur commençait son appel bien après le début du créneau. Une période était jugée un créneau entier plus quatre secondes après LHORODATAGE DU DÉCODAGE — cest-à-dire le début de ce créneau, treize secondes avant que ses décodages existent. Elle est désormais jugée 0,8 s après lARRIVÉE du dernier décodage, et le balayeur tourne quatre fois par seconde au lieu dune fois toutes les deux : la réponse part dans la seconde où les décodages sont tombés, ce quexige la frontière du créneau suivant.", "Auto-call : Halt devient un verdict sur la station appelée — mise de côté pour la session, plus jamais appelée jusqu’à ce que lauto-call soit désactivé puis réactivé. Il peut aussi journaliser chaque décision (Réglages → DXHunter) : une ligne par période disant ce qui était sur lair et pourquoi chaque station a été refusée.",
"Décodages FT : lhorloge de période passe au rouge pendant l’émission. Cest le seul élément qui bouge à l’écran, donc là où l’œil se trouve déjà, et « suis-je en émission » mérite d’être lisible de lautre bout de la pièce.", "Auto-call dit ce quil attend. Une station voulue mais en QSO avec quelquun dautre saffiche désormais à côté du bouton Auto, au lieu de laisser croire quil na rien à faire.",
"Lappel automatique lançait un appel puis le coupait une seconde plus tard, avant de repartir. Deux fautes, toutes deux lisibles dans le journal : le plafond dappels tombe PENDANT l’émission du septième appel — cest elle qui le compte — et il coupait la porteuse sur place, envoyant un demi-appel ; puis la station était reprise quatre secondes après, parce que le repos suivant une série était ignoré dès que rien de mieux n’était sur lair, ce qui, pour la station que tout le monde chasse, est le cas à chaque période. Le plafond laisse désormais l’émission en cours se terminer, et un repos est un repos.", "FT decodes : un message qui VOUS est adressé saffiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de lautre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : lessentiel de ce quelle émet sadresse à dautres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
"La chasse au nouveau suit désormais un changement de chasse (« nouveau » ↔ « nouveau + non confirmé »). Elle juge chaque décodage à son arrivée et ne garde que ce qui était nouveau à cet instant : sa liste était donc un ensemble de verdicts rendus sous lancienne règle, que rien ne rejugeait — passer en « nouveau seulement » y laissait des lignes simplement non confirmées, et linverse ne pouvait pas ramener ce qui avait déjà été refusé. La liste est vidée au changement et se remplit à nouveau depuis le flux en une période ou deux.", "FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à lunité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
"Le Stop devient un verdict sur la station appelée automatiquement : elle est mise de côté pour la session et nest plus appelée jusqu’à ce que lappel automatique soit éteint puis rallumé. Le Stop remettait tout le moteur à zéro — repos et séries compris — si bien que la station quon venait darrêter redevenait éligible dans la seconde et que la période suivante la rappelait. La nommer dans la liste de chasse ne passe pas outre non plus.", "Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant dun profil au log vide, tous les décodes et spots restaient marqués NEW jusquau redémarrage : lindex des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
"Lappel automatique peut journaliser chaque décision (Réglages → DXHunter, sous lappel automatique) : une ligne par période disant ce quil y avait sur lair, combien de stations étaient appelables, pourquoi chacune des autres a été écartée — travaillée, rien à y gagner, en QSO, au repos, arrêtée par lopérateur, épuisée, sans LoTW — et lesquelles ont été classées. Elle répond à la question à laquelle le journal ne pouvait pas répondre : pourquoi rien na été appelé alors que le panneau était plein de stations voulues. Une ligne toutes les quinze secondes : désactivé par défaut.", "Lauto-call est toujours ARRÊTÉ au lancement, et de nouveau après un changement de profil — jamais armé depuis un réglage enregistré. Cest la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows : larmer est un clic que quelquun doit faire.",
"Décodages FT : la liste se vide pour un récepteur qui change de bande. Ce quil y a sur 20 m ne dit rien du 40 m, et un demi-écran de stations devenues inatteignables est pire quun écran vide — les en-têtes de période continuent de défiler et les pastilles se lisent toujours comme des verdicts. Par récepteur : en vue scindée, lautre na pas bougé.", "Lauto-call reprend immédiatement le créneau libéré quand la station appelée savère être en QSO avec quelquun dautre. Il sarrêtait et attendait une période avant de rechoisir, laissant un CQ deux lignes plus bas sans réponse pendant quinze secondes — sauf en pleine émission, où il attend toujours la fin de la séquence.",
"Décodages FT : une pastille rose WL signale une station de votre liste de surveillance, en tête de la colonne Statut. Cest votre propre réponse à « celle-là vaut-elle mon temps », et elle passe devant ce que le log pense de lentité. Reconnue par la règle de la liste elle-même — un * final est un préfixe — pour quune pastille et une alerte ne puissent pas se contredire.", "Widget rotor redessiné : carte du monde nocturne derrière une échelle dazimut carrée, aiguille orange qui suit la souris pour quun clic parte où on vise, repère jaune sur lazimut demandé jusqu’à larrivée de lantenne, et directions rapides en colonnes de six. Le boom Ultrabeam et son deuxième lobe sont toujours tracés, et Station Control garde le cadran seul — rien en dessous, ce qui y était se retrouvait coupé en bas de la rangée. Design proposé par EC1KD — merci à lui.",
"Appel automatique : une station qui VOUS appelle est prise, que le log ait quelque chose à y gagner ou non. Elle était écartée quand il ny avait rien à en tirer : un QSO se terminait, deux stations appelaient à la séquence suivante, et la machine les ignorait toutes les deux. Déjà travaillée sur cette bande et ce mode, ou arrêtée par lopérateur, veut toujours dire non.", "Widget rotor : le bouton Stop ne clignote plus sur un rotor à larrêt. Le mouvement était déduit dun changement dun degré, soit moins que le tremblement de lecture dun contrôleur au repos.",
"Appel automatique : un indicatif surveillé passe désormais devant toute station absente de la liste, au lieu d’être remonté dun cran à la fois. Une DXpédition surveillée dont on navait plus besoin restait au bas de l’échelle et n’était jamais atteinte sur une bande pleine de stations utiles — linverse de ce que veut dire lavoir mise sur la liste. Entre stations surveillées, lordre davant sapplique toujours : entité, bande, mode, slot.", "Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — lun se lit de loin, lautre est le cadran compact davant — et le choix sapplique aussitôt au widget docké comme à Station Control.",
"Décodages FT : la pastille de liste de surveillance passe après le « L » de LoTW, qui tient en une lettre et reste toujours au même endroit — une pastille devant lui décalait toute la colonne Statut dune ligne à lautre.", "Auto-call : une station tout juste choisie ne démarre plus avec des ratés à son compte. Le compteur ignorait sur quelle séquence elle émet tant quelle navait pas été décodée une seconde fois, donc toutes les périodes comptaient — y compris celle passée à lui émettre dessus, où elle est inaudible par définition."
"Lappel automatique cesse dappeler une station qui se met à travailler quelquun dautre. Le fait d’être appelable était vérifié au moment de la CHOISIR et jamais ensuite pendant quon la tenait : une station prise sur son CQ qui répondait à un autre appelant continuait d’être appelée pendant les sept essais — deux minutes d’émission vers une station en QSO qui ne peut pas lentendre. Sa trame finale vers un autre ne compte pas : celle-là est à une période d’être libre, cest le meilleur moment pour lappeler. Relâchée sans verdict, elle redevient appelable avec un quota complet dès quelle repasse CQ.",
"Lappel automatique nappelle plus que ce que la liste des décodages AFFICHE. Les filtres au-dessus delle — CQ seuls, LoTW, les pastilles de catégorie, continents, report minimal, la recherche — pilotent l’émetteur autant que l’œil : une station filtrée hors écran nest pas appelée. Le panneau envoie les indicatifs quil montre et non ses réglages de filtre, si bien quil nexiste quune définition de « affiché » et que le moteur ne peut pas contredire ce que lopérateur a sous les yeux. La liste fermée, rien nest filtré.",
"Panneau PSK Reporter : le créneau dappel suggéré donne désormais toujours une réponse quand il y a des données. Il cherchait une suite de créneaux vides et se taisait quand il nen trouvait pas — cest-à-dire précisément dans le cas pour lequel il existe : cent décodages sur un passe-bande de 2800 Hz ne laissent aucun trou, et le panneau dessinait tout lhistogramme sans un mot de conseil dessous. À défaut de vrai trou, il nomme le créneau le moins encombré, les égalités allant vers le haut, et jamais au-dessus du plafond.",
"Lappel automatique écartait presque toutes les stations sur lair comme « déjà travaillées ». Il lisait le verdict de lENTITÉ comme celui de la station : « travaillé » y signifie que le PAYS est au log sur cette bande et ce mode, donc sur une bande où lopérateur les a presque tous, tout ce qui décodait était refusé — une trace dune soirée montre vingt décodages sur vingt et un écartés ainsi, la DXpédition surveillée parmi eux. Le verdict de lentité décide désormais de ce qui est NÉCESSAIRE, et le créneau propre à lindicatif décide si lappeler ferait un doublon.",
"Lappel automatique lit une trame multi-réponses en entier. MSHV répond à deux stations dans une même émission et le décodeur limprime sur une seule ligne — « YV5ALI RR73; F4BPO <HK0/PY8WW> -08 » — dont la seconde moitié est un report pour nous. En nen lisant que le début, le moteur voyait une station occupée avec quelquun dautre et lâchait la cible au moment précis où le DX répondait. Le panneau des décodages lisait déjà chaque segment ; le moteur le fait désormais aussi, pour « est-ce quil nous appelle », « peut-on lappeler » et « le QSO est-il fini ».",
"Lappel automatique laisse à votre 73 le créneau qui suit un QSO. Le RR73 du DX est décodé pendant que vous êtes entre deux émissions : le décodeur sapprête à envoyer ce 73 dans le créneau qui souvre, et y répondre à quelquun dautre changeait lindicatif en pleine séquence — la trame qui clôt le contact ne partait jamais entière. La station suivante est désormais choisie à la période daprès, quinze secondes plus tard, et elle est toujours là. Il enchaînait aussitôt parce que le créneau est la ressource ; le créneau vaut moins que le QSO quil gâche."
] ]
}, },
{ {
+38 -3
View File
@@ -119,6 +119,8 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal'; import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel'; import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { GridSquareMap } from '@/components/GridSquareMap'; import { GridSquareMap } from '@/components/GridSquareMap';
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros'; import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel'; import { DecodesPanel, type Decode as DecodeRow, type TxMsg as TxMsgRow } from '@/components/DecodesPanel';
@@ -2612,6 +2614,30 @@ export default function App() {
// The band each receiver was last decoding on. A band change empties that // The band each receiver was last decoding on. A band change empties that
// receiver's list — see the flush below. // receiver's list — see the flush below.
const decoderBandRef = useRef<Map<string, string>>(new Map()); const decoderBandRef = useRef<Map<string, string>>(new Map());
// The RIG changing band empties the list too, without waiting for the
// decoder to catch up.
//
// Clicking a watchlist row or a spot tunes the radio; the decoder follows on
// its own clock and its first decode on the new band is up to a period away.
// Until then the operator reads a screen of stations from a band they have
// left — which is the moment the list is most misleading, because it looks
// current. Only the receivers that were on the band we came FROM are
// cleared: a second decoder parked on another band has not moved.
const rigBandRef = useRef<string>('');
useEffect(() => {
const band = (catState.band ?? '').toLowerCase();
const was = rigBandRef.current;
rigBandRef.current = band;
if (!band || !was || was === band) return;
const stale = new Set<string>();
decoderBandRef.current.forEach((b2, inst) => { if (b2 === was) stale.add(inst); });
if (stale.size === 0) return;
stale.forEach((inst) => decoderBandRef.current.delete(inst));
pendingDecodesRef.current = pendingDecodesRef.current.filter((d) => !stale.has(d.instance ?? ''));
setDecodes((arr) => arr.filter((d) => !stale.has(d.instance ?? '')));
setTxMsgs((arr) => arr.filter((m) => !stale.has(m.instance ?? '')));
}, [catState.band]);
const pendingDecodeTimer = useRef<number | undefined>(undefined); const pendingDecodeTimer = useRef<number | undefined>(undefined);
// Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the // Rotor quick-turn buttons (Settings → Rotator). Same reload trigger as the
@@ -2774,6 +2800,11 @@ export default function App() {
// withholding them IS the compact mode, so there is no second layout to keep // withholding them IS the compact mode, so there is no second layout to keep
// in step with the first. // in step with the first.
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1'); const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
// Which dial (Settings → Rotator). Subscribed rather than re-read on close, so
// the choice shows the moment it is made — it is picked by looking at it.
const [rotorDial, setRotorDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setRotorDial(rotorStyle())), []);
const Compass2 = rotorDial === 'classic' ? RotorCompassClassic : RotorCompass;
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
// picked award references to the QSO field/extras each award actually reads. // picked award references to the QSO field/extras each award actually reads.
@@ -6373,6 +6404,7 @@ export default function App() {
// 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 || '') : ''}
myCall={station.callsign} myCall={station.callsign}
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
// WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in // WSJT-X/MSHV as a Reply, the same thing as double-clicking the line in
// their own window. A single click only selects it — see onSelect below, // their own window. A single click only selects it — see onSelect below,
@@ -7558,10 +7590,13 @@ export default function App() {
{/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a {/* Rotor compass: azimuth dial + needles + click-to-turn. Shows when a
rotator is configured or a DX bearing exists. Compact mode drops the rotator is configured or a DX bearing exists. Compact mode drops the
controls column, so the widget is just the dial and needs only its controls column, so the widget is just the dial and needs only its
width. */} width. The classic dial sizes itself from the inside and expects a fixed
column; the current one asks for the width it needs. */}
{showRotor && (rotatorHeading.enabled || dxPath) && ( {showRotor && (rotatorHeading.enabled || dxPath) && (
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}> <div className={cn('shrink-0 min-h-0',
<RotorCompass rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
style={{ order: wOrder('rotor') }}>
<Compass2
presets={rotorCompact ? undefined : rotorPresets} presets={rotorCompact ? undefined : rotorPresets}
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }} onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
bearing={dxPath?.bearingShort ?? null} bearing={dxPath?.bearingShort ?? null}
+64 -18
View File
@@ -17,6 +17,8 @@ import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, B
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { chaseAllows } from '@/lib/spotDisplay'; import { chaseAllows } from '@/lib/spotDisplay';
import { pathBetween } from '@/lib/maidenhead';
import { distanceValue, distanceUnit, subscribeDistanceUnit } from '@/lib/units';
import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers'; import { markerColour, type SpotMarkerKey } from '@/lib/spotMarkers';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { SetAutoCallVisible } from '../../wailsjs/go/main/App'; import { SetAutoCallVisible } from '../../wailsjs/go/main/App';
@@ -105,6 +107,9 @@ interface Props {
// point the panels at it. Absent, a click falls back to onCall. // point the panels at it. Absent, a click falls back to onCall.
onSelect?: (d: Decode) => void; onSelect?: (d: Decode) => void;
myCall?: string; myCall?: string;
// The station's own square: distance is measured from it, and without one the
// column stays empty rather than guessing.
myGrid?: string;
// Drop every decode and transmit message held for this panel. The list is a // Drop every decode and transmit message held for this panel. The list is a
// live view, not data — clearing it costs nothing but the seconds until the // live view, not data — clearing it costs nothing but the seconds until the
// next period lands. // next period lands.
@@ -121,7 +126,7 @@ interface Props {
autoCallOn?: boolean; autoCallOn?: boolean;
onToggleAutoCall?: () => void; onToggleAutoCall?: () => void;
// The engine's own account of what it is doing, straight from the backend. // The engine's own account of what it is doing, straight from the backend.
autoCall?: { target: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string }; autoCall?: { target: string; waiting: string; calls: number; max: number; misses: number; max_miss: number; stopped: boolean; reason: string };
// The chase list, here as well as in Preferences: naming the station you are // The chase list, here as well as in Preferences: naming the station you are
// waiting for is done WHILE watching the band, not in a settings tree. // waiting for is done WHILE watching the band, not in a settings tree.
autoCallOnly?: string; autoCallOnly?: string;
@@ -271,7 +276,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the // One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header // resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body. // and stay the old width in the body.
type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status'; type ColKey = 'time' | 'rx' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'dist' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 }, { key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
// WHICH RECEIVER heard it. Shown only while more than one is feeding, and // WHICH RECEIVER heard it. Shown only while more than one is feeding, and
@@ -292,6 +297,11 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 }, { key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells // For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European. // them out — "SD" alone is a quiz for a European.
// Next to the square it is computed from. A four-character grid is a square
// tens of kilometres wide, so this is rounded to whole units and never
// pretends to more: it answers "is that station across the pond or across the
// valley", which is what decides whether the report is worth anything.
{ key: 'dist', tkey: 'dec.colDist', def: 70, min: 46 },
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 }, { key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 }, { key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 }, { key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
@@ -446,16 +456,31 @@ function periodLabel(ms: number, trSec: number): string {
// read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying // read "CQ CQ PE1NAO JO32" — the badge and the message's own first word saying
// the same thing twice. Highlighting the word already in the line keeps the // the same thing twice. Highlighting the word already in the line keeps the
// scannability and drops the stutter. // scannability and drops the stutter.
function renderMsg(msg: string, me: string, calling: string) { function renderMsg(msg: string, me: string, calling: string, toMe: boolean) {
if (!msg) return null; if (!msg) return null;
// THE WHOLE LINE, when it is addressed to YOU.
//
// Token colouring is for reading the band — it picks your call out of a wall
// of other people's traffic. A message sent TO you is a different question:
// not "is my call in there somewhere" but "what did he just send me", read
// from the far side of the shack, so the whole line carries the colour.
//
// Only that case. The station you are calling also transmits to everybody
// else — "LA8WRA LA1RAU/P +00" is your target reporting to another caller —
// and setting those lines in the same strong colour said you were in a QSO
// you were not in. Its callsign is picked out by the token pass below, which
// is what "he is on the air, working somebody else" should look like.
if (toMe) {
return <span className="font-bold text-success">{msg}</span>;
}
// Split on whitespace and colour the tokens that matter, rather than the // Split on whitespace and colour the tokens that matter, rather than the
// whole line: an operator scanning a slot is looking for their own call in // whole line: an operator scanning a slot is looking for their own call in
// the first position (someone answering) and for the station being called. // the first position (someone answering) and for the station being called.
const parts = msg.split(/(s+)/); const parts = msg.split(/(\s+)/);
return ( return (
<> <>
{parts.map((tok, i) => { {parts.map((tok, i) => {
if (/^s+$/.test(tok)) return tok; if (/^\s+$/.test(tok)) return tok;
const bare = tok.replace(/[<>]/g, '').toUpperCase(); const bare = tok.replace(/[<>]/g, '').toUpperCase();
if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>; if (i === 0 && /^CQ$/i.test(tok)) return <span key={i} className="font-bold text-success">{tok.toUpperCase()}</span>;
if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>; if (me && bare === me) return <span key={i} className="font-bold text-success">{tok}</span>;
@@ -583,7 +608,7 @@ function buildPeriods(filtered: Decode[], txMsgs: TxMsg[]) {
})); }));
} }
export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, onCall, onSelect, myCall, onClear, onHalt, autoCallOn, onToggleAutoCall, autoCall, autoCallOnly, onSetAutoCallOnly, watchlist }: Props) { export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, rigBand, 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/
@@ -674,6 +699,8 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
// opens with our callsign, sometimes bracketed when the sender compressed a // opens with our callsign, sometimes bracketed when the sender compressed a
// non-standard call. // non-standard call.
const me = (myCall ?? '').toUpperCase(); const me = (myCall ?? '').toUpperCase();
const [, bumpUnit] = useState(0);
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
const calling = (txState?.dx_call ?? '').toUpperCase(); const calling = (txState?.dx_call ?? '').toUpperCase();
const answersMe = (msg?: string): boolean => { const answersMe = (msg?: string): boolean => {
if (!me || !msg) return false; if (!me || !msg) return false;
@@ -988,6 +1015,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''} {autoCall.misses > 0 ? ` ·${autoCall.misses}/${autoCall.max_miss}` : ''}
</span> </span>
)} )}
{/* Wanted, decoded, and in a QSO with somebody else. Holding fire
looks exactly like having nothing to do, and the operator had no
way to tell them apart. */}
{autoCallOn && !autoCall?.target && autoCall?.waiting && (
<span className="font-mono text-xs animate-pulse" title={t('dec.autoWaitTip', { call: autoCall.waiting })}>
{autoCall.waiting}
</span>
)}
</button> </button>
)} )}
{/* The chase list. Raw text while typing, committed on blur or Enter: {/* The chase list. Raw text while typing, committed on blur or Enter:
@@ -1145,9 +1180,9 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
i < cols.length - 1 && 'border-r border-border/30', i < cols.length - 1 && 'border-r border-border/30',
// The three numeric columns label their own right edge, where the // The three numeric columns label their own right edge, where the
// figures are. // figures are.
(c.key === 'snr' || c.key === 'dt' || c.key === 'freq') && 'justify-end')} (c.key === 'snr' || c.key === 'dt' || c.key === 'freq' || c.key === 'dist') && 'justify-end')}
title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}> title={c.key === 'dt' ? t('dec.colDtTitle') : c.key === 'freq' ? t('dec.colFreqTitle') : undefined}>
<span className="truncate">{t(c.tkey)}</span> <span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
<ColResizer <ColResizer
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)} onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
onReset={() => setColWidth(c.key, c.def)} onReset={() => setColWidth(c.key, c.def)}
@@ -1227,7 +1262,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
title={t('dec.callTitle', { call: d.call })} title={t('dec.callTitle', { call: d.call })}
style={{ gridTemplateColumns: template, width: tableW }} style={{ gridTemplateColumns: template, width: tableW }}
className={cn(ROW, 'text-left border-b border-border/20 transition-colors', className={cn(ROW, 'text-left border-b border-border/20 transition-colors',
replying ? 'bg-success/20 hover:bg-success/25' replying ? 'bg-success/25 hover:bg-success/30 border-l-2 border-l-success'
// The station being called: a tint saying "he is on the
// air", not the QSO treatment above — most of what he
// sends is to other people.
: worked ? 'bg-danger/15 hover:bg-danger/20' : worked ? 'bg-danger/15 hover:bg-danger/20'
: mine ? 'bg-info/10' : mine ? 'bg-info/10'
: 'hover:bg-muted/50')} : 'hover:bg-muted/50')}
@@ -1282,7 +1320,7 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{/* The message carries the callsign already — which is why {/* The message carries the callsign already — which is why
there is no column repeating it. */} there is no column repeating it. */}
<span className={cn(CELL, 'font-mono text-[13px]')}> <span className={cn(CELL, 'font-mono text-[13px]')}>
<span className="truncate">{renderMsg(d.msg ?? '', me, calling)}</span> <span className="truncate">{renderMsg(d.msg ?? '', me, calling, replying)}</span>
</span> </span>
{/* The decode's own grid first, the remembered one as the {/* The decode's own grid first, the remembered one as the
@@ -1295,6 +1333,14 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
<span className="truncate">{d.grid || e?.grid || ''}</span> <span className="truncate">{d.grid || e?.grid || ''}</span>
</span> </span>
<span className={cn(CELL, 'justify-end font-mono text-[11px] tabular-nums text-muted-foreground/80')}>
{(() => {
const g = d.grid || e?.grid || '';
const path = myGrid && g ? pathBetween(myGrid, g) : null;
return path ? distanceValue(path.distanceShort).toLocaleString() : '';
})()}
</span>
<span className={cn(CELL, 'gap-1.5')}> <span className={cn(CELL, 'gap-1.5')}>
{e?.state && ( {e?.state && (
<> <>
@@ -1315,20 +1361,20 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
{e?.lotw && ( {e?.lotw && (
<span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span> <span className="text-[10px] font-bold text-info-muted-foreground shrink-0" title="LoTW">L</span>
)} )}
{/* After the L, which is one letter and always in the same {e?.worked_call && (
place: a badge in front of it moved the whole column <span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
sideways from row to row. */} {t('dec.wkd')}
</span>
)}
{/* Plainest first: the LoTW letter, the worked note, then
the coloured badges — the watch list leading them, being
the operator's own answer rather than the log's. */}
{isWatched(d.call, watchlist) && ( {isWatched(d.call, watchlist) && (
<span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white" <span className="rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0 text-white"
style={{ background: '#f472b6' }} title={t('dec.wlTip')}> style={{ background: '#f472b6' }} title={t('dec.wlTip')}>
{t('dec.wl')} {t('dec.wl')}
</span> </span>
)} )}
{e?.worked_call && (
<span className="rounded px-1 py-px text-[10px] font-medium bg-muted text-muted-foreground shrink-0">
{t('dec.wkd')}
</span>
)}
{entities.map((b) => ( {entities.map((b) => (
<span key={b.label} <span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined} title={e?.unconf_status ? t('dec.unconfTip') : undefined}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,381 @@
// RotorCompassClassic — the original rotor dial, kept as an option.
//
// Settings → Rotator chooses between this and the current compass. It was
// replaced rather than removed because the two answer the same question in
// different ways: this one is a small light-map dial with the quick turns in a
// column beside it, and an operator used to it should not have to relearn a
// panel to keep working.
//
// An azimuthal-equidistant rotor display (à la 4O3A RotorGenius).
//
// A world map centred on the operator's QTH (north up) fills the dial, ringed by
// a green azimuth bezel. A green needle shows the antenna heading (two needles
// when an Ultrabeam is bidirectional, the opposite one when reversed); a small
// red marker on the bezel shows the short-path bearing to the DX. Click the dial
// to turn the antenna there.
import { useEffect, useMemo, useRef, useState } from 'react';
import { geoAzimuthalEquidistant, geoPath, geoGraticule10 } from 'd3-geo';
import { feature } from 'topojson-client';
import landTopo from 'world-atlas/land-110m.json';
import { Compass, X, Play, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// Decode the coastline outline once (≈110 m simplified land polygons).
const LAND = feature(landTopo as any, (landTopo as any).objects.land);
const GRATICULE = geoGraticule10();
interface Props {
bearing?: number | null; // short-path azimuth to DX (deg)
headings: number[]; // radiating heading(s) — rotor + Ultrabeam pattern
boomHeading?: number | null; // mechanical boom (rotor) azimuth, shown grey when it differs
pattern?: 'normal' | 'reverse' | 'bi' | null; // Ultrabeam pattern (for the badge)
centerLat?: number | null; // operator latitude (projection centre)
centerLon?: number | null; // operator longitude
rotorEnabled?: boolean;
rotors?: string[]; // logical rotor names; >1 → show a selector
activeRotor?: number; // index of the selected rotor (0-based)
onSelectRotor?: (i: number) => void; // switch the active rotor
onGoto?: (az: number) => void; // click-to-turn
onClose?: () => void;
// Quick-turn buttons and the azimuth box, shown only where the caller wants
// them: Station Control draws its own GoTo/Stop around this compass, and two
// sets of the same controls side by side would be nothing but confusing.
presets?: { label: string; azimuth: number }[];
onStop?: () => void;
}
const SIZE = 168;
const C = SIZE / 2;
const R = C - 6; // outer bezel radius
const MAP_R = R - 6; // map/clip radius (inside the bezel)
function pt(az: number, radius: number): [number, number] {
const a = ((az - 90) * Math.PI) / 180;
return [C + radius * Math.cos(a), C + radius * Math.sin(a)];
}
export function RotorCompassClassic({ bearing, headings, boomHeading, pattern, centerLat, centerLon, rotorEnabled, rotors, activeRotor, onSelectRotor, onGoto, onClose, presets, onStop }: Props) {
const { t } = useI18n();
// Raw text, not a number: binding the input to a normalised value makes
// Backspace fight the operator on the way from "230" to "23". It is parsed
// when it is sent, and only then.
const [azText, setAzText] = useState('');
const showControls = !!(presets || onStop);
// Which preset was just pressed, so it can light up for a moment.
//
// A rotor takes seconds to start moving and the needle barely twitches at
// first, so without this the only answer to "did that register?" is to press
// it again — which is how an antenna ends up ordered somewhere twice. The
// acknowledgement has to come from the button itself, at once.
const [flashIdx, setFlashIdx] = useState<number | null>(null);
const flashTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(flashTimer.current), []);
const pressPreset = (i: number, az: number) => {
if (!onGoto) return;
setFlashIdx(i);
window.clearTimeout(flashTimer.current);
flashTimer.current = window.setTimeout(() => setFlashIdx(null), 450);
onGoto(az);
};
// Stop needs the same acknowledgement, for the same reason and one more: it
// is pressed when something is already wrong, and a button that stays inert
// gets hit again and again. Its own flag, so stopping does not blank a preset
// that is still lit.
const [stopFlash, setStopFlash] = useState(false);
const stopTimer = useRef<number | undefined>(undefined);
useEffect(() => () => window.clearTimeout(stopTimer.current), []);
const pressStop = () => {
if (!onStop) return;
setStopFlash(true);
window.clearTimeout(stopTimer.current);
stopTimer.current = window.setTimeout(() => setStopFlash(false), 450);
onStop();
};
// 0-359 and nothing else. 360 is refused rather than folded to 0 — it is
// almost always a typo for 36 or 306, and a rotor swinging through north on a
// slip of the finger is worth one rejected keypress.
const sendAz = () => {
const s = azText.trim();
if (s === '' || !onGoto) return;
const n = Number(s);
if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0 || n > 359) return;
onGoto(n);
setAzText('');
};
const cardinals = useMemo(
() => [ { d: 0, l: 'N' }, { d: 45, l: 'NE' }, { d: 90, l: 'E' }, { d: 135, l: 'SE' },
{ d: 180, l: 'S' }, { d: 225, l: 'SW' }, { d: 270, l: 'W' }, { d: 315, l: 'NW' } ],
[],
);
// Project the world centred on the QTH (north up; antipode at the bezel).
const { land, grat } = useMemo(() => {
if (centerLat == null || centerLon == null) return { land: '', grat: '' };
const proj = geoAzimuthalEquidistant()
.rotate([-centerLon, -centerLat])
.clipAngle(179.9)
.scale(MAP_R / Math.PI)
.translate([C, C]);
const path = geoPath(proj as any);
return { land: path(LAND as any) || '', grat: path(GRATICULE as any) || '' };
}, [centerLat, centerLon]);
function handleClick(e: React.MouseEvent<SVGSVGElement>) {
if (!onGoto) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * SIZE - C;
const y = ((e.clientY - rect.top) / rect.height) * SIZE - C;
let az = (Math.atan2(y, x) * 180) / Math.PI + 90;
az = ((az % 360) + 360) % 360;
onGoto(Math.round(az));
}
const headLabel = headings.length ? headings[0] : null;
// Short and long path to the DX, in figures.
//
// The bezel already carries the short path as a red marker, but a marker is a
// direction, not a number — the same pair sits in the status bar at 10px and
// operators reported not being able to read it. It lives here because it
// belongs to the compass: every place that draws one gets the readout, instead
// of each caller inventing its own. Clickable when the caller can turn, like
// the status bar's. Built as a value because it is placed in one of two
// columns depending on whether the controls are shown.
const pathReadout = (
<div className="flex gap-1.5 mt-2 font-mono w-full">
{([['SP', bearing ?? null], ['LP', bearing == null ? null : (bearing + 180) % 360]] as const).map(([lbl, az]) => (
<button key={lbl} type="button" disabled={az == null || !onGoto}
onClick={() => { if (az != null && onGoto) onGoto(Math.round(az)); }}
title={az == null ? '' : `${lbl} ${Math.round(az)}°`}
className={
'flex-1 rounded-md border py-1 text-xs font-semibold tabular-nums transition-colors active:scale-95 ' +
(az == null
? 'border-border text-muted-foreground/50 cursor-not-allowed'
: onGoto
? 'border-info-border text-info-muted-foreground hover:bg-info-muted cursor-pointer'
: 'border-border text-muted-foreground cursor-default')
}>
{lbl} {az == null ? '—' : `${Math.round(az)}°`}
</button>
))}
</div>
);
return (
<section className="flex flex-col h-full min-h-0 rounded-lg border border-border bg-card overflow-hidden">
{/* Header — matches the WinKeyer / Voice keyer panels. */}
<div className="flex items-center gap-2 px-3 py-1.5 bg-muted/40 border-b border-border shrink-0">
<Compass className="size-4 text-primary shrink-0" />
<span className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Rotor</span>
<span className={cn('size-2 rounded-full', rotorEnabled ? 'bg-success' : 'bg-muted-foreground/40')}
title={rotorEnabled ? 'Rotator connected' : 'Rotator disabled'} />
<div className="flex-1" />
{pattern && (
<span
className={cn('px-1 py-px rounded text-[9px] font-bold tracking-wide',
pattern === 'reverse' ? 'bg-warning-muted text-warning-muted-foreground'
: pattern === 'bi' ? 'bg-info-muted text-info-muted-foreground'
: 'bg-success-muted text-success-muted-foreground')}
title={pattern === 'reverse' ? 'Ultrabeam reversed — radiates opposite the boom'
: pattern === 'bi' ? 'Ultrabeam bidirectional — radiates both ways'
: 'Ultrabeam normal'}>
{pattern === 'reverse' ? 'REV' : pattern === 'bi' ? 'BI' : 'NORM'}
</span>
)}
<span className="font-mono text-sm font-bold text-success tabular-nums">
{headLabel != null ? `${Math.round(headLabel).toString().padStart(3, '0')}°` : '—'}
</span>
{onClose && (
<button className="text-muted-foreground hover:text-foreground" title="Hide rotor" onClick={onClose}>
<X className="size-3.5" />
</button>
)}
</div>
{/* Multiple rotors — pick which one the dial shows and turns. */}
{rotors && rotors.length > 1 && (
<div className="flex flex-wrap gap-1 px-2 pt-1.5">
{rotors.map((nm, i) => {
const active = (activeRotor ?? 0) === i;
const label = nm?.trim() || `Rotor ${i + 1}`;
return (
<button
key={i}
onClick={() => onSelectRotor?.(i)}
className={cn(
'flex-1 min-w-[48px] px-1.5 py-0.5 rounded text-[10px] font-semibold truncate transition-colors',
active
? 'bg-success text-success-foreground'
: 'bg-muted text-muted-foreground hover:bg-muted/70',
)}
title={label}
>
{label}
</button>
);
})}
</div>
)}
{/* Dial on the left, controls on the right. The controls column is sized to
fit WITHIN the dial's height: the widget sits in a row whose height is
set by the entry strip, so it may grow sideways but never downwards. */}
<div className="flex items-start gap-2 p-2 min-h-0">
{/* flex-col: the readout goes BELOW the dial. This wrapper was a row, so a
sibling of the <svg> landed beside it. */}
<div className="flex flex-col items-center justify-center min-h-0 shrink-0">
<svg
viewBox={`0 0 ${SIZE} ${SIZE}`}
className={onGoto ? 'cursor-pointer select-none' : 'select-none'}
style={{ width: SIZE, height: SIZE }}
onClick={handleClick}
>
<defs>
<clipPath id="rotorDial"><circle cx={C} cy={C} r={MAP_R} /></clipPath>
</defs>
{/* water + world map, clipped to the dial */}
<circle cx={C} cy={C} r={MAP_R} fill="#d3e7f1" />
<g clipPath="url(#rotorDial)">
{grat && <path d={grat} fill="none" stroke="#9cc0d6" strokeWidth={0.4} opacity={0.7} />}
{land && <path d={land} fill="#dfe2cf" stroke="#9aa589" strokeWidth={0.4} />}
</g>
{/* green azimuth bezel */}
<circle cx={C} cy={C} r={R} fill="none" stroke="#16a34a" strokeWidth={5} />
{/* ticks every 10°, longer at 30° */}
{Array.from({ length: 36 }, (_, i) => i * 10).map((d) => {
const major = d % 30 === 0;
const [x1, y1] = pt(d, MAP_R);
const [x2, y2] = pt(d, MAP_R - (major ? 7 : 4));
return <line key={d} x1={x1} y1={y1} x2={x2} y2={y2} stroke="#475569" strokeWidth={major ? 1 : 0.6} opacity={0.7} />;
})}
{/* cardinal labels + degree numbers at 45° */}
{cardinals.map(({ d, l }) => {
const [x, y] = pt(d, MAP_R - 13);
return <text key={l} x={x} y={y} textAnchor="middle" dominantBaseline="central" className="fill-slate-700" style={{ fontSize: l.length > 1 ? 7 : 9, fontWeight: 700 }}>{l}</text>;
})}
{/* DX short-path bearing → small red marker on the bezel */}
{bearing != null && (() => { const [x, y] = pt(bearing, MAP_R); return (
<circle cx={x} cy={y} r={3} fill="#dc2626" stroke="#fff" strokeWidth={1} />
); })()}
{/* mechanical boom (rotor) heading — grey dashed needle, shown when the
Ultrabeam radiates somewhere other than the boom (reverse/bi) so the
operator sees where the antenna physically points vs where it boom-sits */}
{boomHeading != null && pattern && pattern !== 'normal' && (() => {
const [x, y] = pt(boomHeading, MAP_R - 2);
return (
<g>
<title>Boom (rotor) {Math.round(boomHeading)}°</title>
<line x1={C} y1={C} x2={x} y2={y} stroke="#64748b" strokeWidth={2} strokeDasharray="3 3" strokeLinecap="round" />
<circle cx={x} cy={y} r={3} fill="#64748b" stroke="#fff" strokeWidth={1} />
</g>
);
})()}
{/* radiating heading needle(s) — green; two when bidirectional */}
{headings.map((h, i) => { const [x, y] = pt(h, MAP_R - 2); return (
<g key={i}>
<line x1={C} y1={C} x2={x} y2={y} stroke="#15803d" strokeWidth={3} strokeLinecap="round" opacity={i === 0 ? 1 : 0.55} />
<polygon points={`${x},${y} ${pt(h - 5, MAP_R - 12).join(',')} ${pt(h + 5, MAP_R - 12).join(',')}`} fill="#15803d" opacity={i === 0 ? 1 : 0.55} />
</g>
); })}
<circle cx={C} cy={C} r={3.5} fill="#15803d" stroke="#fff" strokeWidth={1} />
</svg>
{/* With the controls column present the readout goes at the FOOT OF IT
instead: the dial sets the widget's height, the controls are shorter
than the dial, and that leftover space is exactly the right size for
the pair. Under the dial it would push the whole widget taller. */}
{!showControls && pathReadout}
</div>
{/* Quick turns + free azimuth + Stop. */}
{showControls && (
<div className="flex flex-col gap-1.5 flex-1 min-w-0">
{/* Two columns so six regions fit beside the dial rather than under
it. An operator with fewer keeps the same compact block. */}
{!!presets?.length && (
<div className="grid grid-cols-2 gap-1">
{presets.map((p, i) => (
<button
key={`${p.label}-${i}`}
type="button"
disabled={!onGoto}
onClick={() => pressPreset(i, p.azimuth)}
title={`${p.label}${p.azimuth}°`}
className={cn(
'rounded-md border py-1 text-xs font-semibold truncate transition-all duration-150 active:scale-95',
flashIdx === i
// Lit, and showing the azimuth it just sent: the label alone
// would only say the press landed, not what was ordered.
? 'border-success bg-success text-success-foreground scale-95'
: 'border-border bg-muted/40',
onGoto && flashIdx !== i ? 'hover:bg-muted' : '',
!onGoto ? 'opacity-50 cursor-not-allowed' : '',
)}
>
{flashIdx === i ? `${p.azimuth}°` : p.label}
</button>
))}
</div>
)}
{/* Free azimuth: Enter sends, so the whole thing is type-three-digits
-and-go without reaching for the mouse. */}
<div className="flex gap-1">
<input
type="text"
inputMode="numeric"
value={azText}
onChange={(e) => setAzText(e.target.value.replace(/[^0-9]/g, '').slice(0, 3))}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); sendAz(); } }}
placeholder={t('rotor.azPh')}
title={t('rotor.azTitle')}
disabled={!onGoto}
className="min-w-0 flex-1 rounded-md border border-border bg-background px-2 py-1 text-xs font-mono tabular-nums text-center disabled:opacity-50"
/>
<button
type="button"
onClick={sendAz}
disabled={!onGoto || azText.trim() === ''}
title={t('rotor.go')}
className="flex items-center gap-1 rounded-md border border-success/60 bg-success-muted px-2 py-1 text-xs font-bold text-success-muted-foreground transition-transform hover:bg-success/25 active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Play className="size-3" /> {t('rotor.go')}
</button>
</div>
{onStop && (
<button
type="button"
onClick={pressStop}
title={t('rotor.stop')}
className={cn(
'flex items-center justify-center gap-1.5 rounded-md border py-1 text-xs font-bold transition-all duration-150 active:scale-95',
stopFlash
// Solid, not a tint: STOP reads the same in both languages, so
// the fill is the whole acknowledgement.
? 'border-destructive bg-destructive text-destructive-foreground scale-95'
: 'border-destructive/60 bg-destructive/15 text-destructive hover:bg-destructive/25',
)}
>
<Square className="size-3 fill-current" /> {t('rotor.stop')}
</button>
)}
{/* mt-auto: the readout sits at the FOOT of the column, level with the
bottom of the dial, instead of floating under the Stop button. */}
<div className="mt-auto">{pathReadout}</div>
</div>
)}
</div>
</section>
);
}
+16
View File
@@ -83,6 +83,7 @@ import {
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { setUseMiles } from '@/lib/units'; import { setUseMiles } from '@/lib/units';
import { rotorStyle, setRotorStyle, type RotorStyle } from '@/lib/rotorStyle';
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan'; import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat'; import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n'; import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
@@ -1765,6 +1766,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// the full widget is what the operator has today, and a setting that changes // the full widget is what the operator has today, and a setting that changes
// a panel the moment you upgrade is a setting that gets blamed for it. // a panel the moment you upgrade is a setting that gets blamed for it.
const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1'); const [rotorCompact, setRotorCompact] = useState(() => localStorage.getItem('opslog.rotorCompact') === '1');
const [rotorDial, setRotorDial] = useState<RotorStyle>(() => rotorStyle());
const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1'); const [startEqEnd, setStartEqEnd] = useState(() => localStorage.getItem('opslog.startEqualsEnd') === '1');
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1'); const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1'); const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
@@ -4873,6 +4875,20 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
dial to take a column, not a panel. */} dial to take a column, not a panel. */}
<div className="border-t border-border/60 pt-3 space-y-2"> <div className="border-t border-border/60 pt-3 space-y-2">
<div className="text-sm font-semibold">{t('rot.widget')}</div> <div className="text-sm font-semibold">{t('rot.widget')}</div>
{/* Which dial. Both are kept: the new one reads at a glance across
the shack, the old one is the compact dial operators learned
first, and neither is wrong. */}
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground">{t('rot.dial')}</span>
<select
value={rotorDial}
onChange={(e) => { const v = e.target.value as RotorStyle; setRotorDial(v); setRotorStyle(v); }}
className="h-8 rounded-md border border-border bg-background px-2 text-sm"
>
<option value="modern">{t('rot.dialModern')}</option>
<option value="classic">{t('rot.dialClassic')}</option>
</select>
</div>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox checked={rotorCompact} <Checkbox checked={rotorCompact}
onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} /> onCheckedChange={(c) => { const v = !!c; setRotorCompact(v); writeUiPref('opslog.rotorCompact', v ? '1' : '0'); }} />
@@ -11,6 +11,8 @@ import { useI18n } from '@/lib/i18n';
import { writeUiPref } from '@/lib/uiPref'; import { writeUiPref } from '@/lib/uiPref';
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading'; import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
import { RotorCompass } from '@/components/RotorCompass'; import { RotorCompass } from '@/components/RotorCompass';
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
import { AmpCard } from '@/components/AmpCard'; import { AmpCard } from '@/components/AmpCard';
import { TunerCard } from '@/components/TunerCard'; import { TunerCard } from '@/components/TunerCard';
import type { TGStatus } from '@/components/TunerGeniusPanel'; import type { TGStatus } from '@/components/TunerGeniusPanel';
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
}) { }) {
const [goto, setGoto] = useState(''); const [goto, setGoto] = useState('');
const [err, setErr] = useState(''); const [err, setErr] = useState('');
// Both compasses in the app follow the same preference (Settings → Rotator):
// one operator's choice of dial, not one per panel.
const [dial, setDial] = useState(() => rotorStyle());
useEffect(() => subscribeRotorStyle(() => setDial(rotorStyle())), []);
const Dial = dial === 'classic' ? RotorCompassClassic : RotorCompass;
const turn = (az: number) => { const turn = (az: number) => {
const a = ((Math.round(az) % 360) + 360) % 360; const a = ((Math.round(az) % 360) + 360) % 360;
@@ -145,9 +152,12 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} /> title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
</div> </div>
<div className="p-3 flex gap-4 items-start"> <div className="p-3 flex gap-4 items-start">
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the {/* The compact compass is the dial alone — short and long path are the
app carries it rather than each caller drawing its own. */} two coloured dots on it. The figures are in the status bar. */}
<RotorCompass {/* The dial-only form is square and fills the box it is given, so its
size is set here rather than baked into the component. */}
<div className={dial === 'classic' ? 'shrink-0' : 'w-[210px] shrink-0'}>
<Dial
bearing={bearing ?? null} bearing={bearing ?? null}
headings={hd.ok ? [hd.azimuth] : []} headings={hd.ok ? [hd.azimuth] : []}
centerLat={centerLat ?? null} centerLat={centerLat ?? null}
@@ -158,6 +168,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }} onSelectRotor={(i) => { SetActiveRotor(i).then(refetch).catch((e) => setErr(String(e?.message ?? e))); }}
onGoto={(az) => turn(az)} onGoto={(az) => turn(az)}
/> />
</div>
<div className="flex-1 min-w-0 space-y-2"> <div className="flex-1 min-w-0 space-y-2">
<div className="font-mono"> <div className="font-mono">
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span> <span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
+6 -6
View File
@@ -194,7 +194,7 @@ const en: Dict = {
'ac.only': 'Chase only', 'ac.onlyPh': 'VP6D 3Y0J (space or comma)', 'ac.onlyHint': 'one or more callsigns — nothing else is called; empty means call whatever the log needs', 'ac.only': 'Chase only', 'ac.onlyPh': 'VP6D 3Y0J (space or comma)', 'ac.onlyHint': 'one or more callsigns — nothing else is called; empty means call whatever the log needs',
'ac.attempts': 'Calls before giving up', 'ac.watchedAttempts': 'if watched', 'ac.misses': 'Missed periods', 'ac.attempts': 'Calls before giving up', 'ac.watchedAttempts': 'if watched', 'ac.misses': 'Missed periods',
'ac.rounds': 'Series per station', 'ac.rest': 'Rest (min)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.', 'ac.rounds': 'Series per station', 'ac.rest': 'Rest (min)', 'ac.trace': 'Log every decision', 'ac.traceHint': '— one line per period in the diagnostic log: what was on the air, why each station was refused, and what was decided. For working out why nothing is being called. A line every fifteen seconds, so leave it off otherwise.', 'ac.onScreen': 'Call only what the decodes list is showing', 'ac.onScreenHint': '— the filters above the list (CQ only, LoTW only, the new-category chips, continents, report, search) steer the transmitter too: a station filtered off the screen is not called. With the list closed nothing is filtered.',
'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.', 'dec.chasePh': 'chase…', 'dec.chaseTip': 'Call ONLY these stations — one or more callsigns, spaces or commas. Empty means call whatever the log needs. Same field as Preferences → DXHunter.', 'dec.autoWaitTip': '{call} is wanted and is working somebody else — it is called the moment it is free.', 'dec.autoStoppedTip': 'Auto-call has given up on a station from the chase list — click to switch it off, or Halt to clear it.',
// Contest watchlist (Settings → DXHunter): how a special-event fleet joins the list on its own // Contest watchlist (Settings → DXHunter): how a special-event fleet joins the list on its own
'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)', 'wlc.title': 'Contest', 'wlc.pattern': 'Auto-add on', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'any spotted callsign CONTAINING this joins the watchlist as a contest entry (TM29WWA, HB9WWA, F4WWA/P)',
'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.', 'wlc.calls': 'And these callsigns, one per line', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'For the entries a pattern cannot catch: a station taking part under a callsign that says nothing about the event. Named here, it joins the contest watchlist the moment it is spotted. Commas and spaces work too.',
@@ -213,7 +213,7 @@ const en: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)', 'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you', 'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
'dec.txUnknown': 'transmitting — text not reported', 'dec.txNothing': 'nothing being sent', 'dec.txUnknown': 'transmitting — text not reported', 'dec.txNothing': 'nothing being sent',
'dec.colTime': 'Time', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colState': 'State', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.stateTip': 'US state', 'dec.wl': 'WL', 'dec.wlTip': 'On your watch list', 'dec.wkd': 'Wkd', 'dec.colTime': 'Time', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colDist': 'Dist', 'dec.colState': 'State', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.stateTip': 'US state', 'dec.wl': 'WL', 'dec.wlTip': 'On your watch list', 'dec.wkd': 'Wkd',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgState': 'New State', 'dec.bgCounty': 'CTY', 'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgState': 'New State', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL', 'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).', 'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
@@ -400,7 +400,7 @@ const en: Dict = {
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.useForMyAnt': 'Use the selected antenna as MY_ANTENNA', 'ag2.useForMyAntHint': 'The antenna selected on the switch, under the name it carries there, is written into every QSO as it is logged — ahead of the band default from Operating conditions. Which port counts is decided by the antenna jack the radio is transmitting on.', 'ag2.ant1Port': 'The radio\u2019s ANT1 jack is wired to', 'ag2.portA': 'Port A', 'ag2.portB': 'Port B', 'ag2.ant1PortHint': 'Station wiring — neither the radio nor the switch can report it. ANT2 then goes to the other port.', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.', 'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.useForMyAnt': 'Use the selected antenna as MY_ANTENNA', 'ag2.useForMyAntHint': 'The antenna selected on the switch, under the name it carries there, is written into every QSO as it is logged — ahead of the band default from Operating conditions. Which port counts is decided by the antenna jack the radio is transmitting on.', 'ag2.ant1Port': 'The radio\u2019s ANT1 jack is wired to', 'ag2.portA': 'Port A', 'ag2.portB': 'Port B', 'ag2.ant1PortHint': 'Station wiring — neither the radio nor the switch can report it. ANT2 then goes to the other port.', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.', 'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
'rot.spidHint': 'Native SPID protocol over the controllers COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol', 'rot.spidHint': 'Native SPID protocol over the controllers COM port — no PstRotator needed. Pick the dialect above: Rot2Prog answers with azimuth and elevation at 600 baud, Rot1Prog with azimuth only at 1200.', 'rot.spidModel': 'SPID protocol',
'rot.widget': 'Widget', 'rot.compact': 'Compact mode', 'rot.compactHint': 'Show only the dial and the short/long-path azimuths. The quick-turn buttons, the azimuth box and Stop are hidden — turn the antenna from the bearing pill in the entry strip instead.', 'rot.dial': 'Dial', 'rot.dialModern': 'World map (new)', 'rot.dialClassic': 'Classic dial', 'rot.widget': 'Widget', 'rot.compact': 'Compact mode', 'rot.compactHint': 'Show only the dial and the short/long-path azimuths. The quick-turn buttons, the azimuth box and Stop are hidden — turn the antenna from the bearing pill in the entry strip instead.',
'rot.presets': 'Quick-turn buttons', 'rot.presetName': 'Name', 'rot.presetAdd': 'Add button', 'rot.presets': 'Quick-turn buttons', 'rot.presetName': 'Name', 'rot.presetAdd': 'Add button',
'rot.presetsHint': 'Shown beside the rotor dial. The azimuths started out computed from your station square, so they point at those regions FROM HERE — change either field, or clear a name to drop the button.', 'rot.presetsHint': 'Shown beside the rotor dial. The azimuths started out computed from your station square, so they point at those regions FROM HERE — change either field, or clear a name to drop the button.',
'rot.presetsReset': 'Recompute from my square', 'rot.presetsReset': 'Recompute from my square',
@@ -751,7 +751,7 @@ const fr: Dict = {
'ac.only': 'Chasser uniquement', 'ac.onlyPh': 'VP6D 3Y0J (espace ou virgule)', 'ac.onlyHint': 'un ou plusieurs indicatifs — rien dautre nest appelé ; vide = appeler ce dont le log a besoin', 'ac.only': 'Chasser uniquement', 'ac.onlyPh': 'VP6D 3Y0J (espace ou virgule)', 'ac.onlyHint': 'un ou plusieurs indicatifs — rien dautre nest appelé ; vide = appeler ce dont le log a besoin',
'ac.attempts': 'Appels avant abandon', 'ac.watchedAttempts': 'si surveillé', 'ac.misses': 'Périodes manquées', 'ac.attempts': 'Appels avant abandon', 'ac.watchedAttempts': 'si surveillé', 'ac.misses': 'Périodes manquées',
'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (min)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce quil y avait sur lair, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien nest appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'Nappeler que ce quaffiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran nest pas appelée. La liste fermée, rien nest filtré.', 'ac.rounds': 'Séries par station', 'ac.rest': 'Repos (min)', 'ac.trace': 'Journaliser chaque décision', 'ac.traceHint': '— une ligne par période dans le journal de diagnostic : ce quil y avait sur lair, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien nest appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'Nappeler que ce quaffiche la liste des décodages', 'ac.onScreenHint': '— les filtres au-dessus de la liste (CQ seuls, LoTW, les pastilles de catégorie, continents, report, recherche) pilotent aussi l’émetteur : une station filtrée hors écran nest pas appelée. La liste fermée, rien nest filtré.',
'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.", 'dec.chasePh': 'chasser…', 'dec.chaseTip': "N'appeler QUE ces stations — un ou plusieurs indicatifs, espaces ou virgules. Vide = appeler ce dont le log a besoin. Même champ que Réglages → DXHunter.", 'dec.autoWaitTip': '{call} est voulue et travaille quelquun dautre — elle sera appelée dès quelle sera libre.', 'dec.autoStoppedTip': "L'appel automatique a renoncé à une station de la liste de chasse — cliquez pour l'éteindre, ou Stop pour effacer.",
// Watchlist contest (Réglages → DXHunter) : comment une flotte d’événement rejoint la liste toute seule // Watchlist contest (Réglages → DXHunter) : comment une flotte d’événement rejoint la liste toute seule
'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)', 'wlc.title': 'Contest', 'wlc.pattern': 'Ajout auto sur', 'wlc.patternPh': 'WWA', 'wlc.patternHint': 'tout indicatif spotté CONTENANT ceci rejoint la watchlist comme entrée contest (TM29WWA, HB9WWA, F4WWA/P)',
'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants quaucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès quelle est spottée. Les virgules et les espaces marchent aussi.', 'wlc.calls': 'Et ces indicatifs, un par ligne', 'wlc.callsPh': 'R7W DL0ABC', 'wlc.callsHint': 'Pour les participants quaucun motif ne peut attraper : une station engagée sous un indicatif qui ne dit rien de l’événement. Nommée ici, elle rejoint la watchlist contest dès quelle est spottée. Les virgules et les espaces marchent aussi.',
@@ -770,7 +770,7 @@ const fr: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)', 'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi', 'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
'dec.txUnknown': 'en émission — texte non communiqué', 'dec.txNothing': 'rien en cours d’émission', 'dec.txUnknown': 'en émission — texte non communiqué', 'dec.txNothing': 'rien en cours d’émission',
'dec.colTime': 'Heure', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colState': 'État', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.stateTip': 'État US', 'dec.wl': 'WL', 'dec.wlTip': 'Sur votre liste de surveillance', 'dec.wkd': 'Fait', 'dec.colTime': 'Heure', 'dec.colRx': 'RX', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colDist': 'Dist', 'dec.colState': 'État', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.stateTip': 'État US', 'dec.wl': 'WL', 'dec.wlTip': 'Sur votre liste de surveillance', 'dec.wkd': 'Fait',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgState': 'Nouvel État', 'dec.bgCounty': 'CTY', 'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgState': 'Nouvel État', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND', 'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).", 'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",
@@ -949,7 +949,7 @@ const fr: Dict = {
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.useForMyAnt': "Utiliser l'antenne sélectionnée comme MY_ANTENNA", 'ag2.useForMyAntHint': "L'antenne sélectionnée sur le switch, sous le nom qu'elle y porte, est inscrite dans chaque QSO au moment où il est enregistré — avant l'antenne par défaut des conditions de trafic. C'est la prise d'antenne sur laquelle la radio émet qui décide du port retenu.", 'ag2.ant1Port': 'La prise ANT1 de la radio est câblée sur', 'ag2.portA': 'Port A', 'ag2.portB': 'Port B', 'ag2.ant1PortHint': "Câblage de la station — ni la radio ni le switch ne peuvent le dire. ANT2 va alors sur l'autre port.", 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.useForMyAnt': "Utiliser l'antenne sélectionnée comme MY_ANTENNA", 'ag2.useForMyAntHint': "L'antenne sélectionnée sur le switch, sous le nom qu'elle y porte, est inscrite dans chaque QSO au moment où il est enregistré — avant l'antenne par défaut des conditions de trafic. C'est la prise d'antenne sur laquelle la radio émet qui décide du port retenu.", 'ag2.ant1Port': 'La prise ANT1 de la radio est câblée sur', 'ag2.portA': 'Port A', 'ag2.portB': 'Port B', 'ag2.ant1PortHint': "Câblage de la station — ni la radio ni le switch ne peuvent le dire. ANT2 va alors sur l'autre port.", 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.", 'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID', 'rot.spidHint': 'Protocole SPID natif sur le port COM du contrôleur — sans PstRotator. Choisissez le dialecte ci-dessus : Rot2Prog répond azimut et élévation à 600 bauds, Rot1Prog azimut seul à 1200.', 'rot.spidModel': 'Protocole SPID',
'rot.widget': 'Widget', 'rot.compact': 'Mode compact', 'rot.compactHint': 'Nafficher que le cadran et les azimuts courte/longue distance. Les boutons de rotation rapide, la case dazimut et Stop sont masqués — tourne lantenne depuis la pastille de cap de la barre de saisie.', 'rot.dial': 'Cadran', 'rot.dialModern': 'Carte du monde (nouveau)', 'rot.dialClassic': 'Cadran classique', 'rot.widget': 'Widget', 'rot.compact': 'Mode compact', 'rot.compactHint': 'Nafficher que le cadran et les azimuts courte/longue distance. Les boutons de rotation rapide, la case dazimut et Stop sont masqués — tourne lantenne depuis la pastille de cap de la barre de saisie.',
'rot.presets': 'Boutons de rotation rapide', 'rot.presetName': 'Nom', 'rot.presetAdd': 'Ajouter un bouton', 'rot.presets': 'Boutons de rotation rapide', 'rot.presetName': 'Nom', 'rot.presetAdd': 'Ajouter un bouton',
'rot.presetsHint': "Affichés à côté du cadran du rotor. Les azimuts ont été calculés depuis ton carré : ils visent ces régions DEPUIS ICI. Modifie l'un ou l'autre champ, ou vide le nom pour retirer le bouton.", 'rot.presetsHint': "Affichés à côté du cadran du rotor. Les azimuts ont été calculés depuis ton carré : ils visent ces régions DEPUIS ICI. Modifie l'un ou l'autre champ, ou vide le nom pour retirer le bouton.",
'rot.presetsReset': 'Recalculer depuis mon carré', 'rot.presetsReset': 'Recalculer depuis mon carré',
+39
View File
@@ -0,0 +1,39 @@
// Which rotor dial to draw.
//
// Two compasses ship: the current one (night map, square scale, mouse pointer,
// target marker) and the original small light-map dial. Neither is more correct
// than the other — one operator reads the big map at a glance, another wants the
// compact dial that was there before — so it is a preference, not a migration.
//
// A UI preference rather than a settings-database key: it decides what a widget
// looks like, nothing is transmitted from it, and it belongs to the screen it is
// read on. It is portable all the same (see lib/uiPref), so a copied folder
// keeps the dial its owner chose.
import { writeUiPref } from '@/lib/uiPref';
export const KEY_ROTOR_STYLE = 'opslog.rotorStyle';
export type RotorStyle = 'modern' | 'classic';
export function rotorStyle(): RotorStyle {
try {
return localStorage.getItem(KEY_ROTOR_STYLE) === 'classic' ? 'classic' : 'modern';
} catch {
return 'modern';
}
}
// Same shape as the distance unit: the compasses are rendered from two call
// sites, and a preference changed in Settings has to reach both without either
// of them polling for it.
const listeners = new Set<() => void>();
export function setRotorStyle(style: RotorStyle): void {
writeUiPref(KEY_ROTOR_STYLE, style);
listeners.forEach((l) => l());
}
export function subscribeRotorStyle(fn: () => void): () => void {
listeners.add(fn);
return () => { listeners.delete(fn); };
}
+2
View File
@@ -1986,6 +1986,7 @@ export namespace main {
enabled: boolean; enabled: boolean;
only: string; only: string;
target: string; target: string;
waiting: string;
calls: number; calls: number;
max: number; max: number;
misses: number; misses: number;
@@ -2003,6 +2004,7 @@ export namespace main {
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.only = source["only"]; this.only = source["only"];
this.target = source["target"]; this.target = source["target"];
this.waiting = source["waiting"];
this.calls = source["calls"]; this.calls = source["calls"];
this.max = source["max"]; this.max = source["max"];
this.misses = source["misses"]; this.misses = source["misses"];
+176 -14
View File
@@ -254,6 +254,14 @@ type Engine struct {
// they arrive stamped — and mixing the two gave a rest that expired against // they arrive stamped — and mixing the two gave a rest that expired against
// one reading and a hold that ran against another. // one reading and a hold that ran against another.
now time.Time now time.Time
// waiting is the station the engine WOULD call and cannot, because it is in
// a QSO with somebody else. Kept so the toolbar can say so: an auto-call
// deliberately holding its fire looks exactly like one with nothing to do,
// and from the outside there was no way to tell them apart.
waiting string
// answered says the target has replied to us at least once — the exchange is
// under way and nothing may take its place.
answered bool
// heldSince is when this station BECAME the target, and is never refreshed // heldSince is when this station BECAME the target, and is never refreshed
// while it stays one. It was, and that quietly disabled the clock backstop: // while it stays one. It was, and that quietly disabled the clock backstop:
// a station decoded every period for ever kept pushing its own deadline // a station decoded every period for ever kept pushing its own deadline
@@ -317,6 +325,8 @@ func (e *Engine) Target() string {
// the engine has given up and is waiting for the operator. // the engine has given up and is waiting for the operator.
type Status struct { type Status struct {
Target string `json:"target"` Target string `json:"target"`
// Waiting: wanted, decoded, and in a QSO with somebody else.
Waiting string `json:"waiting"`
Attempts int `json:"attempts"` Attempts int `json:"attempts"`
Max int `json:"max"` Max int `json:"max"`
Misses int `json:"misses"` Misses int `json:"misses"`
@@ -326,7 +336,8 @@ type Status struct {
} }
func (e *Engine) Status() Status { func (e *Engine) Status() Status {
st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn} st := Status{Misses: e.misses, MaxMiss: e.set.Misses, Stopped: e.stopped, StoppedOn: e.stoppedOn,
Waiting: e.waiting}
if e.target != nil { if e.target != nil {
st.Target = e.target.Call st.Target = e.target.Call
st.Attempts = e.attempts st.Attempts = e.attempts
@@ -485,6 +496,13 @@ func messages(msg string) []string {
return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";") return strings.Split(strings.ToUpper(strings.TrimSpace(msg)), ";")
} }
// bare strips the angle brackets a decoder puts round a compressed callsign,
// and upper-cases what is left. "<HP/WE9G>" and "HP/WE9G" are one station, and
// every comparison in here has to agree about that.
func bare(call string) string {
return strings.Trim(strings.ToUpper(strings.TrimSpace(call)), "<>")
}
// addressedTo reports whether one message segment is addressed to a callsign. // addressedTo reports whether one message segment is addressed to a callsign.
// The recipient is the first token, sometimes bracketed when the sender has // The recipient is the first token, sometimes bracketed when the sender has
// compressed a non-standard call. // compressed a non-standard call.
@@ -493,7 +511,7 @@ func addressedTo(part, call string) bool {
if len(toks) == 0 || call == "" { if len(toks) == 0 || call == "" {
return false return false
} }
return strings.Trim(toks[0], "<>") == strings.ToUpper(call) return bare(toks[0]) == bare(call)
} }
// callable reports whether a station can be answered RIGHT NOW. // callable reports whether a station can be answered RIGHT NOW.
@@ -624,6 +642,19 @@ func (e *Engine) OnPeriod(p Period) Action {
t := *e.target t := *e.target
tc := strings.ToUpper(t.Call) tc := strings.ToUpper(t.Call)
// HAS IT ANSWERED US? Settled first, before any of the checks below can
// decide to leave it.
//
// The reply lands in the same period the ladder is re-read, and that
// period used to be judged with answered still false — so a station
// answering our CQ-call was dropped for a better-ranked one in the exact
// period it came back to us. Watched from the shack: mid-exchange with
// V31MA, report sent, and OpsLog switched to a station calling on the
// other side of the screen. A QSO in progress outranks the ladder.
if seen := bestOf(p.Decodes, tc); seen != nil && callingUs(*seen, p.MyCall) {
e.answered = true
}
// The exchange is over — either the station sent us its last frame, or // The exchange is over — either the station sent us its last frame, or
// the log now holds it. Hand straight on to the next station in the same // the log now holds it. Hand straight on to the next station in the same
// period rather than waiting for the next one: the slot is the resource. // period rather than waiting for the next one: the slot is the resource.
@@ -657,6 +688,24 @@ func (e *Engine) OnPeriod(p Period) Action {
p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses, p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting]) map[bool]string{true: " (transmitting)", false: ""}[p.TX.Transmitting])
} }
// SOMETHING BETTER HAS COME ON THE AIR.
//
// A target is held until it answers or a brake releases it, and that is
// right once an exchange has started — but before it has, holding means
// spending the next two minutes on a new slot while the watched
// DXpedition calls CQ three rows above it. Which is what an operator
// sees: the first CQ ignored, then the second one answered because the
// other call had run its course.
//
// So while the target has NOT answered us, a strictly better station
// takes its place. "Better" is the ladder, which does not flicker: a
// station's need does not change from period to period, so this cannot
// oscillate between two of them — only a genuinely higher rung wins.
if !e.answered {
if a, ok := e.preempt(p); ok {
return a
}
}
if seen := bestOf(p.Decodes, tc); seen != nil { if seen := bestOf(p.Decodes, tc); seen != nil {
// The decode is refreshed so a reply carries a current timestamp; the // The decode is refreshed so a reply carries a current timestamp; the
// hold clock is NOT — see heldSince. // hold clock is NOT — see heldSince.
@@ -668,6 +717,7 @@ func (e *Engine) OnPeriod(p Period) Action {
// reply is how two transmissions land in one slot. // reply is how two transmissions land in one slot.
if callingUs(*seen, p.MyCall) { if callingUs(*seen, p.MyCall) {
e.attempts = 0 e.attempts = 0
e.answered = true
// The exchange is under way, so the hold clock starts again from // The exchange is under way, so the hold clock starts again from
// here: it exists to end a call nobody answers, and this one has // here: it exists to end a call nobody answers, and this one has
// been answered. Without this a QSO that began at the end of a // been answered. Without this a QSO that began at the end of a
@@ -689,14 +739,32 @@ func (e *Engine) OnPeriod(p Period) Action {
// is to be calling it. Only a report or a grid to another station // is to be calling it. Only a report or a grid to another station
// means the exchange is under way. // means the exchange is under way.
if !callable(*seen, p.MyCall) { if !callable(*seen, p.MyCall) {
why := fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))
e.drop() e.drop()
return Action{Kind: DoHalt, Soft: true, // AND THE SLOT IS STILL FREE. This period's decodes are in hand,
Reason: fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))} // so the next station is picked from them now instead of fifteen
// seconds from now — the operator watching the screen sees a CQ
// two rows down go unanswered for a whole period, and reads that
// as the engine having missed it.
//
// Never while transmitting: a reply then switches the decoder's
// call mid-over and cuts our own transmission in half, which is
// the whole reason the halt below is Soft.
if !p.TX.Transmitting {
if a := e.pick(p, why); a.Kind == DoReply {
return a
}
}
return Action{Kind: DoHalt, Soft: true, Reason: why}
} }
return Action{} return Action{}
} }
// Absent. Only its OWN transmit periods count against it. // Absent. Only its OWN transmit periods count against it — and never one
// of ours: our transmission is the reason it is not there.
if p.TX.Transmitting {
return Action{}
}
if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot { if e.txSlot >= 0 && slotOf(p.At, periodSecs(p, t)) != e.txSlot {
return Action{} return Action{}
} }
@@ -744,9 +812,18 @@ func (e *Engine) pick(p Period, why string) Action {
only := onlyList(e.set.Only) only := onlyList(e.set.Only)
var best *Candidate var best *Candidate
var bestRank int var bestRank int
// The best station that is wanted, decoded, and working somebody else. It
// is not a candidate — it cannot answer — but it is the reason the engine
// is silent, and the operator is entitled to know that.
waiting, waitingRank := "", 0
for i := range p.Decodes { for i := range p.Decodes {
c := p.Decodes[i] c := p.Decodes[i]
if !e.eligible(c, p, only) { if ok, why := e.judge(c, p, only); !ok {
if why == "busy" {
if r := rank(c); r > waitingRank {
waiting, waitingRank = strings.ToUpper(c.Call), r
}
}
continue continue
} }
r := rank(c) r := rank(c)
@@ -755,14 +832,31 @@ func (e *Engine) pick(p Period, why string) Action {
best, bestRank = &cc, r best, bestRank = &cc, r
} }
} }
e.waiting = waiting
if best == nil { if best == nil {
if waiting != "" {
return Action{Kind: DoNothing,
Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)}
}
if why != "" { if why != "" {
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"} return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
} }
return Action{} return Action{}
} }
e.waiting = ""
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
e.attempts, e.misses, e.txSlot, e.lastMiss = 0, 0, -1, "" // ITS SLOT IS KNOWN FROM THE DECODE THAT MADE IT A CANDIDATE.
//
// This used to start at -1, meaning "parity unknown", and with parity
// unknown the miss counter has nothing to filter on: every period without
// the station counted, INCLUDING the one we spend transmitting to it, where
// it cannot be decoded by definition. An operator answering a CQ saw the
// counter at two misses out of three before the station had had a single
// chance to come back — one bad period from being given up on.
e.attempts, e.misses, e.lastMiss = 0, 0, ""
e.txSlot = slotOf(p.At, periodSecs(p, *best))
e.answered = false
e.waiting = ""
reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need) reason := fmt.Sprintf("calling %s (%s%s)", best.Call, watchedTag(*best), best.Need)
if why != "" { if why != "" {
reason = why + " — " + reason reason = why + " — " + reason
@@ -776,6 +870,10 @@ func (e *Engine) pick(p Period, why string) Action {
func (e *Engine) tracePick(p Period) { func (e *Engine) tracePick(p Period) {
only := onlyList(e.set.Only) only := onlyList(e.set.Only)
refused := map[string]int{} refused := map[string]int{}
// The callsigns too, not only the counts. "worked=2" answers "how many" and
// leaves "which" — and which is the whole question when an operator is
// looking at one station on the screen and asking why it was passed over.
who := map[string][]string{}
type scored struct { type scored struct {
c Candidate c Candidate
r int r int
@@ -786,6 +884,9 @@ func (e *Engine) tracePick(p Period) {
ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])}) ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
} else { } else {
refused[why]++ refused[why]++
if len(who[why]) < 4 {
who[why] = append(who[why], p.Decodes[i].Call)
}
} }
} }
sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r }) sort.Slice(ok, func(i, j int) bool { return ok[i].r > ok[j].r })
@@ -797,7 +898,11 @@ func (e *Engine) tracePick(p Period) {
why := make([]string, 0, len(refused)) why := make([]string, 0, len(refused))
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} { for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
if n := refused[k]; n > 0 { if n := refused[k]; n > 0 {
why = append(why, fmt.Sprintf("%s=%d", k, n)) names := strings.Join(who[k], ",")
if n > len(who[k]) {
names += ",…"
}
why = append(why, fmt.Sprintf("%s=%d(%s)", k, n, names))
} }
} }
e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s", e.trace("period %s rx=%q decodes=%d callable=%d [%s] refused[%s]%s",
@@ -913,12 +1018,56 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
return a.SNR > b.SNR return a.SNR > b.SNR
} }
// preempt hands the slot to a better station, if one is on the air.
func (e *Engine) preempt(p Period) (Action, bool) {
if e.target == nil || p.TX.Transmitting {
// Mid-over: the reply would switch the decoder's call in the middle of
// a transmission. It waits for the gap, like every other call.
return Action{}, false
}
held := rank(*e.target)
heldSeen := bestOf(p.Decodes, strings.ToUpper(e.target.Call)) != nil
only := onlyList(e.set.Only)
best, bestRank := (*Candidate)(nil), held
for i := range p.Decodes {
c := p.Decodes[i]
if strings.EqualFold(c.Call, e.target.Call) || !e.eligible(c, p, only) {
continue
}
// Strictly better takes over. So does an EQUAL rung when the station we
// are calling is not even on the air this period: the seven calls are
// going into the void while a station of the same value is calling CQ
// three rows above it, which is what an operator sees and cannot explain.
r := rank(c)
switch {
case r > bestRank:
case r == bestRank && !heldSeen && best == nil:
case best != nil && r == bestRank && better(c, r, *best, bestRank, p.MyCall):
default:
continue
}
{
cc := c
best, bestRank = &cc, r
}
}
if best == nil {
return Action{}, false
}
was := strings.ToUpper(e.target.Call)
e.drop()
e.target, e.heldSince, e.targetInst = best, p.At, best.Instance
return Action{Kind: DoReply, Decode: best.Decode,
Reason: fmt.Sprintf("%s (%s%s) takes over from %s — nothing had been answered yet",
best.Call, watchedTag(*best), best.Need, was)}, true
}
// drop lets the target go with no verdict attached: not worked, not given up // drop lets the target go with no verdict attached: not worked, not given up
// on, not rested. It is simply not the station to be calling right now, and it // on, not rested. It is simply not the station to be calling right now, and it
// is picked again like any other the moment it is. // is picked again like any other the moment it is.
func (e *Engine) drop() { func (e *Engine) drop() {
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, "" e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
e.targetInst = "" e.targetInst, e.answered = "", false
} }
// addressee is who a message is being sent to — the first token, which is the // addressee is who a message is being sent to — the first token, which is the
@@ -933,14 +1082,14 @@ func addressee(msg string) string {
if len(toks) == 0 { if len(toks) == 0 {
return "someone else" return "someone else"
} }
return strings.Trim(toks[0], "<>") return bare(toks[0])
} }
// release clears the target. gaveUp marks it as a series that ended in a brake // release clears the target. gaveUp marks it as a series that ended in a brake
// rather than in a QSO. // rather than in a QSO.
func (e *Engine) release(call string, gaveUp bool) { func (e *Engine) release(call string, gaveUp bool) {
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, "" e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
e.targetInst = "" e.targetInst, e.answered = "", false
if !gaveUp { if !gaveUp {
// A finished QSO is not a failed series — the callsign keeps its rounds — // A finished QSO is not a failed series — the callsign keeps its rounds —
// but it IS finished: nothing more is wanted from this station today, // but it IS finished: nothing more is wanted from this station today,
@@ -988,19 +1137,32 @@ func (e *Engine) NoteTX(tx TXState) Action {
return Action{} return Action{}
} }
t := *e.target t := *e.target
tc := strings.ToUpper(t.Call) tc := bare(t.Call)
msg := strings.ToUpper(strings.TrimSpace(tx.Msg)) msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
switch { switch {
case msg != "": case msg != "":
toks := strings.Fields(msg) toks := strings.Fields(msg)
if len(toks) < 3 || toks[0] != tc || !isGrid(toks[2]) { // bare(): a decoder compresses a non-standard callsign into angle
// brackets — "<HP/WE9G> F4BPO JN36" is a call to HP/WE9G — and comparing
// the raw token left the counter at 0/15 while the operator watched call
// after call go out. Every brake downstream of it was dead too.
if len(toks) < 3 || bare(toks[0]) != tc {
return Action{}
}
if !isGrid(toks[2]) {
// A report, an R-report, RR73: we are INSIDE the exchange, not
// opening it. It does not count as a call — and it settles that
// nothing may take this station's place, which matters when the
// decoder is answering somebody the engine never picked (the
// operator double-clicked a row) and no reply has been decoded yet.
e.answered = true
return Action{} return Action{}
} }
default: default:
// JTDX reports no transmit message. The DX call is all there is, so // JTDX reports no transmit message. The DX call is all there is, so
// every transmit period aimed at the target counts — more generous, and // every transmit period aimed at the target counts — more generous, and
// far better than a counter frozen at zero for ever. // far better than a counter frozen at zero for ever.
if strings.ToUpper(strings.TrimSpace(tx.DXCall)) != tc { if bare(tx.DXCall) != tc {
return Action{} return Action{}
} }
} }
+122
View File
@@ -795,3 +795,125 @@ func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
t.Errorf("next period: %+v, want the new band called", a) t.Errorf("next period: %+v, want the new band called", a)
} }
} }
func TestABetterStationTakesOverBeforeAnybodyHasAnswered(t *testing.T) {
e := on()
e.OnPeriod(period(0, cq("PLAIN", NeedSlot, +10)))
// A watched station comes on the air: nothing has answered us yet, so the
// call in progress is worth less than the one now possible.
a := e.OnPeriod(period(2, cq("PLAIN", NeedSlot, +10), cq("WATCHED", NeedNone, -15, watched)))
if a.Kind != DoReply || a.Decode.Call != "WATCHED" {
t.Fatalf("%+v — a better station did not take over", a)
}
// Equal rungs do NOT take over while the station being called is on the air.
e = on()
e.OnPeriod(period(4, cq("A", NeedBand, 0)))
if a := e.OnPeriod(period(6, cq("A", NeedBand, 0), cq("B", NeedBand, +20))); a.Kind != DoNothing {
t.Errorf("%+v — swapped between two stations of equal value", a)
}
// But they do when it is absent: seven calls into the void while a station
// of the same value is calling CQ is what the operator was watching.
if a := e.OnPeriod(period(8, cq("B", NeedBand, +20))); a.Kind != DoReply || a.Decode.Call != "B" {
t.Errorf("%+v — kept calling a station that was not on the air", a)
}
// Once the station has answered, nothing takes its place.
e = on()
e.OnPeriod(period(10, cq("DX", NeedSlot, 0)))
e.OnPeriod(period(12, callsMe("DX", NeedSlot, 0)))
if a := e.OnPeriod(period(14, callsMe("DX", NeedSlot, 0), cq("RARE", NeedDXCC, 0, watched))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned an exchange in progress", a)
}
}
// TestAnExchangeInProgressIsNeverAbandoned is the shack report: mid-QSO with
// V31MA — its report decoded, our RR73 going out — and a station of a higher
// rung called us from the other side of the screen. The engine switched.
func TestAnExchangeInProgressIsNeverAbandoned(t *testing.T) {
e := on()
// V31MA is worth nothing on the ladder: worked before, nothing needed.
if a := e.OnPeriod(period(0, callsMe("V31MA", NeedNone, -12))); a.Kind != DoReply {
t.Fatalf("%+v — a station calling us was not answered", a)
}
// Its report arrives in the same period a better station calls us. That
// period used to be judged before the reply was taken into account.
a := e.OnPeriod(period(2,
callsMe("V31MA", NeedNone, -12),
callsMe("F5NNN", NeedSlot, -10)))
if a.Kind != DoNothing {
t.Fatalf("%+v — left V31MA mid-exchange", a)
}
if e.target == nil || e.target.Call != "V31MA" {
t.Fatalf("target is %v — the QSO in progress lost its place", e.target)
}
// Our own transmission settles it too: a report is not an opening call, so
// even a QSO the operator started by hand is protected.
e = on()
e.OnPeriod(period(4, cq("V31MA", NeedNone, -12, watched)))
e.NoteTX(TXState{Transmitting: true, Msg: "V31MA F4BPO RR73"})
if !e.answered {
t.Error("sending a report did not count as being inside the exchange")
}
if a := e.OnPeriod(period(6, cq("V31MA", NeedNone, -12, watched), callsMe("F5NNN", NeedDXCC, -10))); a.Kind != DoNothing {
t.Errorf("%+v — abandoned a QSO we were in the middle of", a)
}
if e.attempts != 0 {
t.Errorf("attempts=%d — an exchange frame was counted as a call", e.attempts)
}
}
func TestTheSlotIsNotWastedWhenTheTargetTurnsOutToBeBusy(t *testing.T) {
e := on()
if a := e.OnPeriod(period(0, cq("DX", NeedBand, -7))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// It answers somebody else, and a station of the same value is calling CQ
// in the very same period. Waiting for the next one throws away a slot.
a := e.OnPeriod(period(2, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8)))
if a.Kind != DoReply || a.Decode.Call != "ER1CW" {
t.Fatalf("%+v — the freed slot was not used", a)
}
if !strings.Contains(a.Reason, "cannot answer") || !strings.Contains(a.Reason, "calling ER1CW") {
t.Errorf("reason %q says neither what was left nor what was taken", a.Reason)
}
// Mid-over, it still waits: cutting our own transmission in half is worse
// than losing the slot.
e = on()
e.OnPeriod(period(4, cq("DX", NeedBand, -7)))
pp := period(6, busy("DX", NeedBand, -7), cq("ER1CW", NeedBand, -8))
pp.TX = TXState{Transmitting: true}
if a := e.OnPeriod(pp); a.Kind != DoHalt || !a.Soft {
t.Errorf("%+v — replied over our own transmission", a)
}
}
// TestTheFirstCallStartsWithNoMisses is the shack report: a CQ answered for the
// first time, and the toolbar already reading two misses out of three.
func TestTheFirstCallStartsWithNoMisses(t *testing.T) {
e := on()
// It calls CQ on an odd slot; we answer.
if a := e.OnPeriod(period(1, cq("ER1CW", NeedBand, -2))); a.Kind != DoReply {
t.Fatalf("%+v", a)
}
// The next period is OURS: we are transmitting to it, and of course it is
// not decoded. That is not a miss.
pp := period(2)
pp.TX = TXState{Transmitting: true}
e.OnPeriod(pp)
if e.misses != 0 {
t.Fatalf("misses=%d after our own transmit period", e.misses)
}
// Nor is a period of the wrong parity with nothing in it.
e.OnPeriod(period(4))
if e.misses != 0 {
t.Fatalf("misses=%d — counted a period the station never transmits in", e.misses)
}
// ITS period, and it is not there: that is a miss.
e.OnPeriod(period(3))
if e.misses != 1 {
t.Fatalf("misses=%d — the station's own silent period was not counted", e.misses)
}
}