feat(grids): the square map filters by mode, band and satellite

The FTx button is gone. It was the wrong grain in both directions: the
Digital class already sat a contest RTTY square next to an FT8 one, and
FTx then sat FT8 next to FT4, when what this map answers is where ONE
mode has been heard. The four classes stay as buttons and a dropdown
beside them offers a single mode.

Its contents come from the log, not from a list in the code. That is
what settles FT2: it is not a registered ADIF mode yet, so a hardcoded
list meant either leaving out the operators already using it or shipping
a mode that does not officially exist. A query does neither, and needs
no change here the day it is registered. The mode offered is the SUBMODE
where there is one, because ADIF files PSK63 under PSK and "PSK" is not
the name anybody is looking for.

The band list is the station's own, unioned with anything worked outside
it so nothing in the log is unreachable, ordered by frequency through
the band plan's index — sorting the names puts 10m between 1.25m and
12m. The satellite list is drawn from SAT_NAME on the squares
themselves and the control is absent altogether on a terrestrial log: a
VHF square worked through AO-91 and one worked line-of-sight are not the
same achievement, and until now nothing separated them.

The mode dropdown shares the scope with the class buttons rather than
narrowing on top of them — mode is one question, and two controls both
answering it is how a map ends up showing PHONE ∩ FT8 and nothing else.
A stored FTX preference is read as Digital, so it cannot leave the map
filtered by something no control shows as selected.
This commit is contained in:
2026-09-10 11:07:25 +02:00
parent 2615365684
commit 052fc4cb80
9 changed files with 289 additions and 39 deletions
+81 -12
View File
@@ -5372,6 +5372,21 @@ var awardBandPlan = []struct {
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
}
// bandOrder ranks a band name by frequency, for sorting a list of them.
//
// The band plan is already written low to high, so its index IS the order —
// which beats sorting the names, where 10m lands between 1.25m and 12m.
// Anything not in the plan sorts last rather than first: an unrecognised band
// is almost always a typo in an imported file, and it belongs at the bottom.
func bandOrder(name string) int {
name = strings.ToLower(strings.TrimSpace(name))
for i, b := range awardBandPlan {
if b.name == name {
return i
}
}
return len(awardBandPlan)
}
func bandForHz(hz int64) string {
for _, b := range awardBandPlan {
if hz >= b.lo && hz <= b.hi {
@@ -7556,34 +7571,88 @@ func (a *App) SendDecodeFreeText(instance, text string, send bool) error {
// GridSquares returns the 4-character Maidenhead squares in the log, with
// whether each is confirmed.
//
// modeClass scopes it the way the rest of the app does: "DIGI", "CW", "PHONE",
// or "" for every mode. "DIGI" is the one this was built for — a map of the
// squares worked on FT8/FT4 answers "where have I actually been heard" in a way
// no list of callsigns does.
// mode scopes it the way the rest of the app does "DIGI", "CW", "PHONE", or
// "" for every mode — but it also takes ONE mode's own name, which is what an
// operator actually wants here: "digital" put a contest RTTY square beside an
// FT8 one and called them the same answer, when the question behind this map is
// where a particular mode has been heard.
//
// band and satName narrow it further, both empty meaning no restriction. The
// satellite is the reason this map is worth filtering at all for anyone chasing
// grids through a bird: a VHF square worked terrestrially and one worked through
// AO-91 are not the same achievement, and nothing else told them apart.
//
// Confirmed means LoTW, a card or eQSL — the same three the award engine counts,
// so a square cannot be green here and unconfirmed in the Awards panel.
func (a *App) GridSquares(modeClass string) ([]qso.GridSquare, error) {
func (a *App) GridSquares(mode, band, satName string) ([]qso.GridSquare, error) {
if a.qso == nil {
return nil, fmt.Errorf("db not initialized")
}
want := strings.ToUpper(strings.TrimSpace(modeClass))
keep := func(mode string) bool {
want := strings.ToUpper(strings.TrimSpace(mode))
wantBand := strings.ToLower(strings.TrimSpace(band))
wantSat := strings.ToUpper(strings.TrimSpace(satName))
keepMode := func(r qso.GridSquareRow) bool {
switch want {
case "", "ALL":
return true
case "FTX":
// The FT family alone, which is narrower than digital and usually the
// honest answer beside an FTx panel: a square worked on RTTY in a
// contest is not a square worked on FT8.
return ftxModes[strings.ToUpper(strings.TrimSpace(mode))]
// Retired from the UI, kept because a stored preference may still say
// it: the FT family alone, narrower than digital.
return ftxModes[r.Mode] || ftxModes[r.Submode]
case "PHONE", "CW", "DIGI":
return award.ModeClass(r.Mode) == want
default:
return award.ModeClass(mode) == want
// One named mode. Matched against the SUBMODE as well, because ADIF
// files PSK63 under PSK: a log carrying the pair would answer nothing
// at all to the only name the operator recognises.
return r.Mode == want || r.Submode == want
}
}
keep := func(r qso.GridSquareRow) bool {
if !keepMode(r) {
return false
}
if wantBand != "" && wantBand != "all" && r.Band != wantBand {
return false
}
if wantSat != "" && wantSat != "ALL" && r.SatName != wantSat {
return false
}
return true
}
return a.qso.GridSquares(a.ctx, keep)
}
// GridSquareChoices is what the grid map's three filters can offer: the modes,
// bands and satellites the squares in the log were actually worked on.
//
// Read from the log, not from a list here, so a filter never offers a choice
// with nothing behind it and never omits one with something. FT2 is the case
// that settled it: it is not a registered ADIF mode yet, and hardcoding the
// list meant either leaving out operators already using it or shipping a mode
// that is not official — a query does neither.
type GridSquareChoices struct {
Modes []string `json:"modes"`
Bands []string `json:"bands"`
Satellites []string `json:"satellites"`
}
func (a *App) GridSquareChoices() (GridSquareChoices, error) {
if a.qso == nil {
return GridSquareChoices{}, fmt.Errorf("db not initialized")
}
modes, bands, sats, err := a.qso.GridSquareChoices(a.ctx)
if err != nil {
return GridSquareChoices{}, err
}
// Bands in frequency order, the way every other band list in the app reads;
// modes and satellites alphabetically, there being no other order for them.
sort.Slice(bands, func(i, j int) bool { return bandOrder(bands[i]) < bandOrder(bands[j]) })
sort.Strings(modes)
sort.Strings(sats)
return GridSquareChoices{Modes: modes, Bands: bands, Satellites: sats}, nil
}
// SetCompactMode toggles a tiny always-on-top window that exposes just the
// QSO entry — useful when running on a single screen alongside WSJT-X,
// JT-Alert or the cluster.