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:
@@ -2603,7 +2603,20 @@ func (a *App) switchLogbook(p profile.Profile) error {
|
||||
_ = old.Close()
|
||||
}
|
||||
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 {
|
||||
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
|
||||
wruntime.EventsEmit(a.ctx, "logbook:changed")
|
||||
}
|
||||
return nil
|
||||
@@ -16704,10 +16717,13 @@ func (a *App) reloadAfterProfileSwitch() {
|
||||
// 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
|
||||
// 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
|
||||
// of the process, and applyAutoCall clears the target when the feature is
|
||||
// off in the profile just activated.
|
||||
a.applyAutoCall()
|
||||
// of the process.
|
||||
a.disarmAutoCall("profile switch")
|
||||
a.autoCallEngine().Reset()
|
||||
}
|
||||
|
||||
|
||||
+28
-8
@@ -195,8 +195,10 @@ type AutoCallStatus struct {
|
||||
// Only is the chase list, carried in the status so the field in the decodes
|
||||
// toolbar and the one in Preferences are never two versions of the truth:
|
||||
// whichever is typed into, both show it.
|
||||
Only string `json:"only"`
|
||||
Target string `json:"target"`
|
||||
Only string `json:"only"`
|
||||
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"`
|
||||
Max int `json:"max"`
|
||||
Misses int `json:"misses"`
|
||||
@@ -218,7 +220,7 @@ func (a *App) GetAutoCallStatus() AutoCallStatus {
|
||||
set := a.GetAutoCallSettings()
|
||||
return AutoCallStatus{
|
||||
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,
|
||||
Greylisted: a.autoCallEngine().Greylisted(),
|
||||
Reason: reason,
|
||||
@@ -588,16 +590,34 @@ func (a *App) autoCallWatched(call string) bool {
|
||||
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
|
||||
// that came up already calling a station chosen before the last shutdown is not
|
||||
// something an operator can be expected to anticipate.
|
||||
// It is never on from a stored value. This is the one setting in the program
|
||||
// that puts the station on the air by itself, and the operator who left it on
|
||||
// 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() {
|
||||
a.applyAutoCall()
|
||||
a.disarmAutoCall("launch")
|
||||
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() {
|
||||
// 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
|
||||
|
||||
+42
-54
@@ -3,62 +3,50 @@
|
||||
"version": "0.27.13",
|
||||
"date": "",
|
||||
"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: 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: 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: a band selection of its own, under the option (160 m to 70 cm). The station’s 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.",
|
||||
"Auto-call cut your own 73 in half. When the DX’s 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-X’s 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.",
|
||||
"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 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 DECODE’S 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.",
|
||||
"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 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.",
|
||||
"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.",
|
||||
"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 engine’s 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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 list’s own rule — a trailing * is a prefix — so a badge and an alert cannot disagree about the same station.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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 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 ENTITY’s verdict as the station’s: “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 entity’s verdict now decides what is NEEDED, and the callsign’s 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 DX’s 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."
|
||||
"Chase new: a band selection of its own, under the option (160 m to 70 cm). The station’s band list still applies underneath — this one says what is worth WATCHING tonight.",
|
||||
"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 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.",
|
||||
"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.",
|
||||
"Cluster: “superfox”, “super fox”, “fox/hound” and “F/H” in a spot comment are read as FT8. They are WSJT-X’s DXpedition transmit modes, not modes of their own, and the comment fell through to the band’s default.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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 MSHV’s two-answers-in-one-line, or refuses nearly everything on the air because it read the entity’s “worked” as the station’s.",
|
||||
"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.",
|
||||
"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: 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"Switching profile no longer leaves the previous logbook’s 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 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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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."
|
||||
],
|
||||
"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 qu’il arrive quoi que ce soit, ce qui est la première question quand elle semble morte.",
|
||||
"Chasse au nouveau : le compteur de l’en-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 d’annoncer ~300 km quel que soit le réglage.",
|
||||
"Chasse au nouveau : un filtrage qui masque tout n’est 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 d’une catégorie — masquait tout le panneau à chaque lancement, sans rien dire.",
|
||||
"Chasse au nouveau : une sélection de bandes qui lui est propre, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on 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 à l’arrivée, elle cesse de prendre une place.",
|
||||
"L’appel 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 n’est pas mis en file : WSJT-X l’exécute aussitôt, abandonne l’échange et se met à appeler la nouvelle station. Plus rien n’est 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.",
|
||||
"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 n’est pas la même prise qu’une 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.",
|
||||
"Panneau PSK Reporter : la requête d’historique s’exécute désormais dans LES DEUX portées de flux, et elle est refaite toutes les cinq minutes tant qu’une station est surveillée. Elle n’avait lieu qu’une fois, et seulement sur le flux étroit — une cible choisie peu après l’ouverture 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 : c’est cette cadence qui est désormais suivie.",
|
||||
"Panneau PSK Reporter : les textes annonçaient « depuis 5 min » alors que la fenêtre était passée à dix. C’est la fenêtre qui avait changé, pas les phrases.",
|
||||
"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 qu’un ZD8 sur 21.071 se lisait comme un nouveau slot DATA au lieu du nouveau FT8 qu’il est.",
|
||||
"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.",
|
||||
"L’appel 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 L’HORODATAGE DU DÉCODAGE — c’est-à-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 l’ARRIVÉE du dernier décodage, et le balayeur tourne quatre fois par seconde au lieu d’une fois toutes les deux : la réponse part dans la seconde où les décodages sont tombés, ce qu’exige la frontière du créneau suivant.",
|
||||
"Décodages FT : l’horloge de période passe au rouge pendant l’émission. C’est 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 l’autre bout de la pièce.",
|
||||
"L’appel 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 d’appels tombe PENDANT l’émission du septième appel — c’est 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 l’air, 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.",
|
||||
"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 l’ancienne règle, que rien ne rejugeait — passer en « nouveau seulement » y laissait des lignes simplement non confirmées, et l’inverse 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.",
|
||||
"Le Stop devient un verdict sur la station appelée automatiquement : elle est mise de côté pour la session et n’est plus appelée jusqu’à ce que l’appel automatique soit éteint puis rallumé. Le Stop remettait tout le moteur à zéro — repos et séries compris — si bien que la station qu’on venait d’arrê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.",
|
||||
"L’appel automatique peut journaliser chaque décision (Réglages → DXHunter, sous l’appel automatique) : une ligne par période disant ce qu’il y avait sur l’air, 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 l’opé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 n’a été appelé alors que le panneau était plein de stations voulues. Une ligne toutes les quinze secondes : désactivé par défaut.",
|
||||
"Décodages FT : la liste se vide pour un récepteur qui change de bande. Ce qu’il y a sur 20 m ne dit rien du 40 m, et un demi-écran de stations devenues inatteignables est pire qu’un é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, l’autre n’a pas bougé.",
|
||||
"Décodages FT : une pastille rose WL signale une station de votre liste de surveillance, en tête de la colonne Statut. C’est votre propre réponse à « celle-là vaut-elle mon temps », et elle passe devant ce que le log pense de l’entité. Reconnue par la règle de la liste elle-même — un * final est un préfixe — pour qu’une pastille et une alerte ne puissent pas se contredire.",
|
||||
"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 n’y 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 l’opérateur, veut toujours dire non.",
|
||||
"Appel automatique : un indicatif surveillé passe désormais devant toute station absente de la liste, au lieu d’être remonté d’un cran à la fois. Une DXpédition surveillée dont on n’avait plus besoin restait au bas de l’échelle et n’était jamais atteinte sur une bande pleine de stations utiles — l’inverse de ce que veut dire l’avoir mise sur la liste. Entre stations surveillées, l’ordre d’avant s’applique toujours : entité, bande, mode, slot.",
|
||||
"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 d’une ligne à l’autre.",
|
||||
"L’appel automatique cesse d’appeler une station qui se met à travailler quelqu’un d’autre. Le fait d’être appelable était vérifié au moment de la CHOISIR et jamais ensuite pendant qu’on 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 l’entendre. Sa trame finale vers un autre ne compte pas : celle-là est à une période d’être libre, c’est le meilleur moment pour l’appeler. Relâchée sans verdict, elle redevient appelable avec un quota complet dès qu’elle repasse CQ.",
|
||||
"L’appel automatique n’appelle plus que ce que la liste des décodages AFFICHE. Les filtres au-dessus d’elle — 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 n’est pas appelée. Le panneau envoie les indicatifs qu’il montre et non ses réglages de filtre, si bien qu’il n’existe qu’une définition de « affiché » et que le moteur ne peut pas contredire ce que l’opérateur a sous les yeux. La liste fermée, rien n’est filtré.",
|
||||
"Panneau PSK Reporter : le créneau d’appel 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 n’en trouvait pas — c’est-à-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 l’histogramme 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.",
|
||||
"L’appel automatique écartait presque toutes les stations sur l’air comme « déjà travaillées ». Il lisait le verdict de l’ENTITÉ 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ù l’opérateur les a presque tous, tout ce qui décodait était refusé — une trace d’une soirée montre vingt décodages sur vingt et un écartés ainsi, la DXpédition surveillée parmi eux. Le verdict de l’entité décide désormais de ce qui est NÉCESSAIRE, et le créneau propre à l’indicatif décide si l’appeler ferait un doublon.",
|
||||
"L’appel automatique lit une trame multi-réponses en entier. MSHV répond à deux stations dans une même émission et le décodeur l’imprime sur une seule ligne — « YV5ALI RR73; F4BPO <HK0/PY8WW> -08 » — dont la seconde moitié est un report pour nous. En n’en lisant que le début, le moteur voyait une station occupée avec quelqu’un d’autre 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 qu’il nous appelle », « peut-on l’appeler » et « le QSO est-il fini ».",
|
||||
"L’appel 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 s’apprête à envoyer ce 73 dans le créneau qui s’ouvre, et y répondre à quelqu’un d’autre changeait l’indicatif 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 d’aprè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 qu’il gâche."
|
||||
"Chase new : sélection de bandes propre au panneau, sous l’option (160 m à 70 cm). La liste de bandes de la station s’applique toujours en dessous — celle-ci dit ce qu’on veut SURVEILLER ce soir.",
|
||||
"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.",
|
||||
"Chase new, corrections : l’en-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 d’anciens verdicts à l’écran.",
|
||||
"Panneau PSK Reporter, corrections : l’historique 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 n’affiche plus une page vide ; l’offset 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.",
|
||||
"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.",
|
||||
"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 qu’un écran vide.",
|
||||
"FT decodes : un badge WL rose marque une station de votre watchlist, et l’horloge de période passe au rouge en émission. C’est le seul élément qui bouge à l’écran, donc « suis-je en émission » se lit de loin.",
|
||||
"Auto-call : série de corrections issues de l’utilisation en trafic. Il ne coupe plus votre 73, n’appelle plus avec quatre à six secondes de retard, ne lance plus un appel pour le couper une seconde après, n’insiste plus sur une station qui s’est mise à travailler quelqu’un d’autre, 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 qu’il prenait le « worked » de l’entité pour celui de la station.",
|
||||
"Auto-call : ordre de priorité plus strict. Un indicatif en watchlist passe devant tout ce qui n’y est pas, un vrai besoin passe devant un besoin non confirmé de même niveau, et une meilleure station peut prendre la place d’une autre qui n’a pas encore répondu — mais jamais celle d’un QSO en cours : dès que la station vous a répondu, ou que vous lui avez envoyé un report, rien ne la remplace.",
|
||||
"Auto-call n’appelle 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. L’option distincte « utilisateurs LoTW uniquement » disparaît.",
|
||||
"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 l’auto-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 l’air et pourquoi chaque station a été refusée.",
|
||||
"Auto-call dit ce qu’il attend. Une station voulue mais en QSO avec quelqu’un d’autre s’affiche désormais à côté du bouton Auto, au lieu de laisser croire qu’il n’a rien à faire.",
|
||||
"FT decodes : un message qui VOUS est adressé s’affiche en vert, en entier et en gras, avec un liseré vert sur la ligne — lisible de l’autre bout du shack. La station que vous appelez garde une teinte légère et son indicatif en rouge : l’essentiel de ce qu’elle émet s’adresse à d’autres, et colorer ces lignes pareil laissait croire à un QSO en cours.",
|
||||
"FT decodes : une colonne distance, à côté du locator dont elle est calculée et dans votre unité (km ou miles). Arrondie à l’unité — un locator à quatre caractères est un carré de plusieurs dizaines de kilomètres, rien de plus fin ne serait honnête.",
|
||||
"Changer de profil ne laisse plus les verdicts du carnet précédent à l’écran. En revenant d’un profil au log vide, tous les décodes et spots restaient marqués NEW jusqu’au redémarrage : l’index des contacts, la liste chase new et les verdicts en cache sont désormais vidés au changement de carnet.",
|
||||
"L’auto-call est toujours ARRÊTÉ au lancement, et de nouveau après un changement de profil — jamais armé depuis un réglage enregistré. C’est la seule fonction qui met la station en émission toute seule, et OpsLog démarre avec Windows : l’armer est un clic que quelqu’un doit faire.",
|
||||
"L’auto-call reprend immédiatement le créneau libéré quand la station appelée s’avère être en QSO avec quelqu’un d’autre. Il s’arrê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.",
|
||||
"Widget rotor redessiné : carte du monde nocturne derrière une échelle d’azimut carrée, aiguille orange qui suit la souris pour qu’un clic parte où on vise, repère jaune sur l’azimut demandé jusqu’à l’arrivée de l’antenne, 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.",
|
||||
"Widget rotor : le bouton Stop ne clignote plus sur un rotor à l’arrêt. Le mouvement était déduit d’un changement d’un degré, soit moins que le tremblement de lecture d’un contrôleur au repos.",
|
||||
"Réglages → Rotator permet de choisir le cadran : la nouvelle boussole carte du monde ou le cadran classique. Les deux sont conservés — l’un se lit de loin, l’autre est le cadran compact d’avant — et le choix s’applique aussitôt au widget docké comme à Station Control.",
|
||||
"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 qu’elle n’avait 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."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+38
-3
@@ -119,6 +119,8 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||
import { GridSquareMap } from '@/components/GridSquareMap';
|
||||
import { loadClusterMacros, visibleClusterMacros } from '@/lib/clusterMacros';
|
||||
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
|
||||
// receiver's list — see the flush below.
|
||||
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);
|
||||
|
||||
// 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
|
||||
// in step with the first.
|
||||
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
|
||||
// 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".
|
||||
rigBand={catState.connected ? (catState.band || '') : ''}
|
||||
myCall={station.callsign}
|
||||
myGrid={station.my_grid}
|
||||
// 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
|
||||
// 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
|
||||
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
|
||||
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) && (
|
||||
<div className={cn('shrink-0 min-h-0', rotorCompact ? 'w-[196px]' : 'w-[320px]')} style={{ order: wOrder('rotor') }}>
|
||||
<RotorCompass
|
||||
<div className={cn('shrink-0 min-h-0',
|
||||
rotorCompact ? 'w-[196px]' : rotorDial === 'classic' ? 'w-[320px]' : 'w-auto')}
|
||||
style={{ order: wOrder('rotor') }}>
|
||||
<Compass2
|
||||
presets={rotorCompact ? undefined : rotorPresets}
|
||||
onStop={rotorCompact ? undefined : () => { RotatorStop().then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
|
||||
@@ -17,6 +17,8 @@ import { AlertTriangle, Radio, Search, X, Signal, ArrowUpRight, Timer, Trash2, B
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
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 { writeUiPref } from '@/lib/uiPref';
|
||||
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.
|
||||
onSelect?: (d: Decode) => void;
|
||||
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
|
||||
// live view, not data — clearing it costs nothing but the seconds until the
|
||||
// next period lands.
|
||||
@@ -121,7 +126,7 @@ interface Props {
|
||||
autoCallOn?: boolean;
|
||||
onToggleAutoCall?: () => void;
|
||||
// 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
|
||||
// waiting for is done WHILE watching the band, not in a settings tree.
|
||||
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
|
||||
// resize handles all read from this, so a column cannot be resized in the header
|
||||
// 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 }[] = [
|
||||
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
|
||||
// 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 },
|
||||
// For the WAS chasers: the badge carries the two letters, the name spells
|
||||
// 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: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
|
||||
{ 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
|
||||
// the same thing twice. Highlighting the word already in the line keeps the
|
||||
// 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;
|
||||
// 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
|
||||
// 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.
|
||||
const parts = msg.split(/(s+)/);
|
||||
const parts = msg.split(/(\s+)/);
|
||||
return (
|
||||
<>
|
||||
{parts.map((tok, i) => {
|
||||
if (/^s+$/.test(tok)) return tok;
|
||||
if (/^\s+$/.test(tok)) return tok;
|
||||
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 (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();
|
||||
// Column widths, dragged in the header and shared by every row. Persisted
|
||||
// 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
|
||||
// non-standard call.
|
||||
const me = (myCall ?? '').toUpperCase();
|
||||
const [, bumpUnit] = useState(0);
|
||||
useEffect(() => subscribeDistanceUnit(() => bumpUnit((n) => n + 1)), []);
|
||||
const calling = (txState?.dx_call ?? '').toUpperCase();
|
||||
const answersMe = (msg?: string): boolean => {
|
||||
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}` : ''}
|
||||
</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>
|
||||
)}
|
||||
{/* 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',
|
||||
// The three numeric columns label their own right edge, where the
|
||||
// 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}>
|
||||
<span className="truncate">{t(c.tkey)}</span>
|
||||
<span className="truncate">{c.key === 'dist' ? `${t(c.tkey)} (${distanceUnit()})` : t(c.tkey)}</span>
|
||||
<ColResizer
|
||||
onResize={(dx) => setColWidth(c.key, colw[c.key] + dx)}
|
||||
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 })}
|
||||
style={{ gridTemplateColumns: template, width: tableW }}
|
||||
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'
|
||||
: mine ? 'bg-info/10'
|
||||
: '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
|
||||
there is no column repeating it. */}
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
<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')}>
|
||||
{e?.state && (
|
||||
<>
|
||||
@@ -1315,20 +1361,20 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, r
|
||||
{e?.lotw && (
|
||||
<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
|
||||
place: a badge in front of it moved the whole column
|
||||
sideways from row to row. */}
|
||||
{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>
|
||||
)}
|
||||
{/* 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) && (
|
||||
<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')}>
|
||||
{t('dec.wl')}
|
||||
</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) => (
|
||||
<span key={b.label}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -83,6 +83,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { setUseMiles } from '@/lib/units';
|
||||
import { rotorStyle, setRotorStyle, type RotorStyle } from '@/lib/rotorStyle';
|
||||
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
|
||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||
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
|
||||
// a panel the moment you upgrade is a setting that gets blamed for it.
|
||||
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 [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '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. */}
|
||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||
<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">
|
||||
<Checkbox checked={rotorCompact}
|
||||
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 { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import { RotorCompassClassic } from '@/components/RotorCompassClassic';
|
||||
import { rotorStyle, subscribeRotorStyle } from '@/lib/rotorStyle';
|
||||
import { AmpCard } from '@/components/AmpCard';
|
||||
import { TunerCard } from '@/components/TunerCard';
|
||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||
@@ -128,6 +130,11 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
|
||||
}) {
|
||||
const [goto, setGoto] = 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 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')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
{/* The SP/LP readout lives INSIDE RotorCompass, so every compass in the
|
||||
app carries it rather than each caller drawing its own. */}
|
||||
<RotorCompass
|
||||
{/* The compact compass is the dial alone — short and long path are the
|
||||
two coloured dots on it. The figures are in the status bar. */}
|
||||
{/* 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}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
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))); }}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
|
||||
@@ -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.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.',
|
||||
'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
|
||||
'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.',
|
||||
@@ -213,7 +213,7 @@ const en: Dict = {
|
||||
'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.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.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).',
|
||||
@@ -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.',
|
||||
'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 controller’s 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.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',
|
||||
@@ -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 d’autre n’est 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.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 qu’il y avait sur l’air, pourquoi chaque station a été écartée, et ce qui a été décidé. Pour comprendre pourquoi rien n’est appelé. Une ligne toutes les quinze secondes : à laisser éteint le reste du temps.', 'ac.onScreen': 'N’appeler que ce qu’affiche 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 n’est pas appelée. La liste fermée, rien n’est 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 quelqu’un d’autre — elle sera appelée dès qu’elle 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
|
||||
'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 qu’aucun 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 qu’elle 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.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.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.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).",
|
||||
@@ -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.",
|
||||
'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.widget': 'Widget', 'rot.compact': 'Mode compact', 'rot.compactHint': 'N’afficher que le cadran et les azimuts courte/longue distance. Les boutons de rotation rapide, la case d’azimut et Stop sont masqués — tourne l’antenne 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': 'N’afficher que le cadran et les azimuts courte/longue distance. Les boutons de rotation rapide, la case d’azimut et Stop sont masqués — tourne l’antenne 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.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é',
|
||||
|
||||
@@ -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); };
|
||||
}
|
||||
@@ -1986,6 +1986,7 @@ export namespace main {
|
||||
enabled: boolean;
|
||||
only: string;
|
||||
target: string;
|
||||
waiting: string;
|
||||
calls: number;
|
||||
max: number;
|
||||
misses: number;
|
||||
@@ -2003,6 +2004,7 @@ export namespace main {
|
||||
this.enabled = source["enabled"];
|
||||
this.only = source["only"];
|
||||
this.target = source["target"];
|
||||
this.waiting = source["waiting"];
|
||||
this.calls = source["calls"];
|
||||
this.max = source["max"];
|
||||
this.misses = source["misses"];
|
||||
|
||||
+177
-15
@@ -254,6 +254,14 @@ type Engine struct {
|
||||
// they arrive stamped — and mixing the two gave a rest that expired against
|
||||
// one reading and a hold that ran against another.
|
||||
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
|
||||
// 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
|
||||
@@ -316,7 +324,9 @@ func (e *Engine) Target() string {
|
||||
// Status is what the panel shows: who, how far into the brakes, and whether
|
||||
// the engine has given up and is waiting for the operator.
|
||||
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"`
|
||||
Max int `json:"max"`
|
||||
Misses int `json:"misses"`
|
||||
@@ -326,7 +336,8 @@ type Status struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
st.Target = e.target.Call
|
||||
st.Attempts = e.attempts
|
||||
@@ -485,6 +496,13 @@ func messages(msg string) []string {
|
||||
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.
|
||||
// The recipient is the first token, sometimes bracketed when the sender has
|
||||
// compressed a non-standard call.
|
||||
@@ -493,7 +511,7 @@ func addressedTo(part, call string) bool {
|
||||
if len(toks) == 0 || call == "" {
|
||||
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.
|
||||
@@ -624,6 +642,19 @@ func (e *Engine) OnPeriod(p Period) Action {
|
||||
t := *e.target
|
||||
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 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.
|
||||
@@ -657,6 +688,24 @@ func (e *Engine) OnPeriod(p Period) Action {
|
||||
p.Key, tc, e.attempts, e.maxAttempts(t), e.misses, e.set.Misses,
|
||||
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 {
|
||||
// The decode is refreshed so a reply carries a current timestamp; the
|
||||
// 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.
|
||||
if callingUs(*seen, p.MyCall) {
|
||||
e.attempts = 0
|
||||
e.answered = true
|
||||
// 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
|
||||
// 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
|
||||
// means the exchange is under way.
|
||||
if !callable(*seen, p.MyCall) {
|
||||
why := fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))
|
||||
e.drop()
|
||||
return Action{Kind: DoHalt, Soft: true,
|
||||
Reason: fmt.Sprintf("%s is working %s — it cannot answer", tc, addressee(seen.Msg))}
|
||||
// AND THE SLOT IS STILL FREE. This period's decodes are in hand,
|
||||
// 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{}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return Action{}
|
||||
}
|
||||
@@ -744,9 +812,18 @@ func (e *Engine) pick(p Period, why string) Action {
|
||||
only := onlyList(e.set.Only)
|
||||
var best *Candidate
|
||||
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 {
|
||||
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
|
||||
}
|
||||
r := rank(c)
|
||||
@@ -755,14 +832,31 @@ func (e *Engine) pick(p Period, why string) Action {
|
||||
best, bestRank = &cc, r
|
||||
}
|
||||
}
|
||||
e.waiting = waiting
|
||||
if best == nil {
|
||||
if waiting != "" {
|
||||
return Action{Kind: DoNothing,
|
||||
Reason: fmt.Sprintf("waiting for %s — it is working somebody else", waiting)}
|
||||
}
|
||||
if why != "" {
|
||||
return Action{Kind: DoNothing, Reason: why + " — nothing else worth calling"}
|
||||
}
|
||||
return Action{}
|
||||
}
|
||||
e.waiting = ""
|
||||
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)
|
||||
if why != "" {
|
||||
reason = why + " — " + reason
|
||||
@@ -776,6 +870,10 @@ func (e *Engine) pick(p Period, why string) Action {
|
||||
func (e *Engine) tracePick(p Period) {
|
||||
only := onlyList(e.set.Only)
|
||||
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 {
|
||||
c Candidate
|
||||
r int
|
||||
@@ -786,6 +884,9 @@ func (e *Engine) tracePick(p Period) {
|
||||
ok = append(ok, scored{p.Decodes[i], rank(p.Decodes[i])})
|
||||
} else {
|
||||
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 })
|
||||
@@ -797,7 +898,11 @@ func (e *Engine) tracePick(p Period) {
|
||||
why := make([]string, 0, len(refused))
|
||||
for _, k := range []string{"worked", "nothing-needed", "busy", "resting", "halted", "parked", "hidden", "not-chased", "replay", "self"} {
|
||||
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",
|
||||
@@ -913,12 +1018,56 @@ func better(a Candidate, ra int, b Candidate, rb int, myCall string) bool {
|
||||
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
|
||||
// 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.
|
||||
func (e *Engine) drop() {
|
||||
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
|
||||
@@ -933,14 +1082,14 @@ func addressee(msg string) string {
|
||||
if len(toks) == 0 {
|
||||
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
|
||||
// rather than in a QSO.
|
||||
func (e *Engine) release(call string, gaveUp bool) {
|
||||
e.target, e.attempts, e.misses, e.txSlot, e.lastMiss = nil, 0, 0, -1, ""
|
||||
e.targetInst = ""
|
||||
e.targetInst, e.answered = "", false
|
||||
if !gaveUp {
|
||||
// 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,
|
||||
@@ -988,19 +1137,32 @@ func (e *Engine) NoteTX(tx TXState) Action {
|
||||
return Action{}
|
||||
}
|
||||
t := *e.target
|
||||
tc := strings.ToUpper(t.Call)
|
||||
tc := bare(t.Call)
|
||||
msg := strings.ToUpper(strings.TrimSpace(tx.Msg))
|
||||
switch {
|
||||
case 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{}
|
||||
}
|
||||
default:
|
||||
// JTDX reports no transmit message. The DX call is all there is, so
|
||||
// every transmit period aimed at the target counts — more generous, and
|
||||
// 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{}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -795,3 +795,125 @@ func TestTheSlotAfterAQSOBelongsToOur73(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user