Files
OpsLog/spotcolors.go
T
rouggy 4c4b3b6c2d feat(flex): send SmartSDR's spot priority; fix(chase): the same station on the same slot has nothing left to give
The panadapter has finite room: spots close in frequency are stacked
behind a '+' and only one is drawn, chosen by PRIORITY — a parameter
'spot add' accepts and OpsLog never sent. So a new entity sat invisible
behind three stations already in the log. DXHunter has sent it for
years; the tiers here are the same idea in the operator's own words:
the entity never worked (with my own callsign, for a multi-op), then
band/mode/slot, then the reference hunts, then everything else.

And a callsign already worked on this exact band and mode stops
advertising a need. Working it again cannot turn a missing QSL into a
confirmation — the QSO is already there — so shouting NEW DXCC over a
station worked an hour ago only teaches an operator to distrust the
colour. The need is real and stays on every OTHER station of the entity,
which is where it can be answered. Applied on both paths out of the
verdict, including the early one that leaves the loop first.
2026-09-01 23:33:56 +02:00

354 lines
12 KiB
Go

package main
// Panadapter spot colours, per status.
//
// A FlexRadio draws every OpsLog spot in one colour today, which throws away the
// only thing worth knowing at a glance on a waterfall: whether the station is
// worth stopping for. The radio's own API has always taken a text colour AND a
// background colour per spot — see internal/cat/flex.go — so the information can
// be carried without a single extra pixel of screen.
//
// The statuses are the ones the cluster already computes (see spotEntityStatus),
// plus the three orthogonal markers an operator chases separately.
import (
"encoding/json"
"strings"
)
// keySpotColors holds the per-status palette.
const keySpotColors = "flex.spot_colors"
// SpotColor is one status's pair. Both are #AARRGGBB — alpha FIRST, which is
// SmartSDR's order, not the web's. An empty background leaves the radio's own.
type SpotColor struct {
Text string `json:"text"`
Bg string `json:"bg,omitempty"`
// Hide keeps this status OFF the panadapter entirely.
//
// Expressed as "hide" and not "send" so the zero value means SEND: every
// palette stored before this existed has to keep behaving as it did, and a
// missing field must never be read as "the operator turned this off".
//
// A colour and a switch rather than one control: the operator who stops
// drawing already-worked stations today may want them back tomorrow, and
// their colour should still be there when they do.
Hide bool `json:"hide,omitempty"`
}
// SpotColors is the whole palette, keyed by the status names the cluster uses.
//
// Stored as a map rather than a struct with nine fields so a status added later
// (a new award marker, say) needs no migration: an unknown key is ignored and a
// missing one falls back to the default below.
type SpotColors struct {
// Enabled off sends no colour at all, which is the behaviour before this
// existed: every spot in the radio's default orange. Kept as a real switch so
// turning it off is a genuine revert rather than "some other palette".
Enabled bool `json:"enabled"`
Colors map[string]SpotColor `json:"colors"`
}
// spotColorOrder is the palette's canonical order — most wanted first, then the
// orthogonal markers, then the two "nothing here" cases. The settings panel
// renders it in this order, and it is the order the resolver tries.
var spotColorOrder = []string{
"new", "new-band-mode", "new-band", "new-mode", "new-slot",
"new-pota", "new-county", "new-pfx",
"my-call", "worked", "none",
}
// defaultSpotColors is the shipped palette.
//
// Text colours are opaque and bright, backgrounds are the same hue at a fifth of
// the alpha: a waterfall is dark and busy, and a solid plate behind the callsign
// hides the very signal the operator is looking at. The scale runs red → amber →
// blue for "never had it" → "missing a piece" → "already yours".
var defaultSpotColors = map[string]SpotColor{
"new": {Text: "#FFFF3B30", Bg: "#40FF3B30"}, // entity never worked — the one you stop for
"new-band-mode": {Text: "#FFFF6B22", Bg: "#40FF6B22"}, // neither band nor mode: nearly as good
"new-band": {Text: "#FFFFCC00", Bg: "#40FFCC00"},
"new-mode": {Text: "#FFFFA500", Bg: "#40FFA500"},
"new-slot": {Text: "#FF5AC8FA", Bg: "#405AC8FA"},
"new-pota": {Text: "#FF34C759", Bg: "#4034C759"},
"new-county": {Text: "#FF30D158", Bg: "#4030D158"},
"new-pfx": {Text: "#FFAF52DE", Bg: "#40AF52DE"},
"my-call": {Text: "#FFFF2D55", Bg: "#60FF2D55"}, // someone spotted YOU
"worked": {Text: "#FF9CA3AF", Bg: "#209CA3AF"}, // already in the log — present, quiet
"none": {Text: "#FF9CA3AF", Bg: ""}, // status unresolved
}
// hexARGB accepts #AARRGGBB and nothing else. The Flex refuses anything shorter
// with a command error the operator never sees, so a half-typed value must not
// reach the radio — it is dropped here and the default is used instead.
func hexARGB(s string) bool {
s = strings.TrimSpace(s)
if len(s) != 9 || s[0] != '#' {
return false
}
for i := 1; i < len(s); i++ {
c := s[i]
if !(c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F') {
return false
}
}
return true
}
// normSpotColors drops anything that is not a valid colour, so the stored
// palette can never make the radio reject a spot.
func normSpotColors(s SpotColors) SpotColors {
out := SpotColors{Enabled: s.Enabled, Colors: map[string]SpotColor{}}
known := map[string]bool{}
for _, k := range spotColorOrder {
known[k] = true
}
for k, v := range s.Colors {
if !known[k] {
continue
}
c := SpotColor{Hide: v.Hide}
if hexARGB(v.Text) {
c.Text = strings.ToUpper(strings.TrimSpace(v.Text))
}
if hexARGB(v.Bg) {
c.Bg = strings.ToUpper(strings.TrimSpace(v.Bg))
}
// Kept when it carries ANY decision — a status with no colours but "hide"
// set is a real instruction, and dropping it would silently switch that
// status back on.
if c.Text != "" || c.Bg != "" || c.Hide {
out.Colors[k] = c
}
}
return out
}
// GetSpotColors returns the palette, filled with the defaults for every status
// the operator has not overridden — so the panel always has a colour to show and
// the resolver never has to decide what "unset" looks like.
func (a *App) GetSpotColors() SpotColors {
out := SpotColors{Enabled: true, Colors: map[string]SpotColor{}}
if raw := a.settingOr(keySpotColors, ""); raw != "" {
var stored SpotColors
if err := json.Unmarshal([]byte(raw), &stored); err == nil {
stored = normSpotColors(stored)
out.Enabled = stored.Enabled
out.Colors = stored.Colors
}
}
for _, k := range spotColorOrder {
if _, ok := out.Colors[k]; !ok {
out.Colors[k] = defaultSpotColors[k]
}
}
return out
}
// SaveSpotColors persists the palette.
func (a *App) SaveSpotColors(s SpotColors) error {
b, err := json.Marshal(normSpotColors(s))
if err != nil {
return err
}
a.setSetting(keySpotColors, string(b))
a.spotColorsMu.Lock()
a.spotColorsCache = nil // re-read on the next spot
a.spotColorsMu.Unlock()
return nil
}
// ResetSpotColors puts the shipped palette back.
func (a *App) ResetSpotColors() SpotColors {
a.setSetting(keySpotColors, "")
a.spotColorsMu.Lock()
a.spotColorsCache = nil
a.spotColorsMu.Unlock()
return a.GetSpotColors()
}
// spotColorFor picks the pair for one spot status.
//
// Cached, because this runs on EVERY spot from every cluster — a busy evening is
// several a second, and re-reading and re-parsing the settings JSON for each one
// would put a database round trip in the middle of the cluster read loop.
func (a *App) spotColorFor(status string) SpotColor {
a.spotColorsMu.Lock()
if a.spotColorsCache == nil {
c := a.GetSpotColors()
a.spotColorsCache = &c
}
p := a.spotColorsCache
a.spotColorsMu.Unlock()
if !p.Enabled {
return SpotColor{}
}
if c, ok := p.Colors[status]; ok {
return c
}
return p.Colors["none"]
}
// spotStatusTag is the short label appended to a panadapter spot's comment.
//
// The colour says WHAT a spot is only to someone who has learnt the palette; the
// word says it to everyone, including the operator who set the colours a month
// ago. Both, then — the same choice DXHunter makes, whose spots read
// "10 dB 18 WPM CQ [AC0C] [United States] [NEW CTY]".
//
// Short on purpose: the comment shares a panadapter with the signals, and a
// spot label that runs over its neighbours hides the very thing it describes.
// "worked" and an unresolved entity get nothing at all — there is no news in
// either, and the absence of a tag is itself the answer.
func spotStatusTag(status string) string {
switch status {
case "new":
return "New DXCC"
case "new-band-mode":
return "New B&M"
case "new-band":
return "New Band"
case "new-mode":
return "New Mode"
case "new-slot":
return "New Slot"
case "new-pota":
return "New POTA"
case "new-county":
return "New Cnty"
case "new-pfx":
return "New Pfx"
case "my-call":
return "Me"
case "worked":
return "Worked"
}
return ""
}
// spotComment builds what the panadapter shows under a spot:
//
// CQ up 2 [F4BPO] [Franz Josef Land] [New Slot]
//
// the cluster's own text, then WHO spotted it, then the entity, then what it is
// worth. Same order and same shape as DXHunter, whose users read these at a
// glance and should not have to learn a second convention.
//
// # The spaces are not spaces
//
// SmartSDR's command line separates parameters ON SPACES, so a comment written
// with ordinary ones arrives at the radio as "CQup2[F4BPO]" — every word run
// together, which is exactly how ours looked. Non-breaking spaces (U+00A0) pass
// through the parser untouched and render as spaces on the panadapter. This is
// the trick DXHunter uses, and there is no other way to get a readable comment
// through that protocol.
func spotComment(comment, spotter, country, status string) string {
var b strings.Builder
add := func(s string) {
s = strings.TrimSpace(s)
if s == "" {
return
}
if b.Len() > 0 {
b.WriteString(" ")
}
b.WriteString(s)
}
// The radio has the last word on length: a comment past roughly sixty
// characters comes back from SmartSDR cut mid-word — one was echoed as
// "…[European Russia" with the closing bracket and the status gone. So the
// parts are added in order of what an operator would keep, and the cluster's
// own words — the longest and the least specific — are the ones trimmed.
// The cluster's own text arrives padded: RBN lines are column-aligned, so a
// comment reads "FT8 5 dB LN14 CQ" with runs of spaces holding the
// columns apart. Those runs became runs of NON-BREAKING spaces, which the
// panadapter faithfully rendered as a gap wide enough to push the rest of the
// comment off the screen. Columns mean nothing once the text is out of its
// table: one space between words.
comment = strings.Join(strings.Fields(comment), " ")
tag := bracket(spotStatusTag(status))
spot := bracket(spotter)
cty := bracket(country)
room := panCommentMax - len(tag) - len(spot) - len(cty) - 3 // 3 separators
add(trimTo(comment, room))
add(spot)
add(cty)
add(tag)
// Every space, including the ones already inside the cluster's comment.
return strings.ReplaceAll(b.String(), " ", "\u00A0")
}
// panCommentMax is what SmartSDR keeps of a spot comment. Measured against a
// real radio's own status echo, with a margin: the cost of being wrong is a
// comment cut mid-word, and nothing in the protocol reports the limit.
const panCommentMax = 58
// trimTo shortens s to at most n characters, on a word boundary when it can.
// A negative or tiny budget yields nothing at all — the brackets that follow
// matter more than a two-letter fragment of the cluster's text.
func trimTo(s string, n int) string {
s = strings.TrimSpace(s)
if n < 4 {
return ""
}
if len(s) <= n {
return s
}
cut := s[:n]
if i := strings.LastIndex(cut, " "); i > n/2 {
cut = cut[:i]
}
return strings.TrimSpace(cut)
}
// bracket wraps a non-empty value in square brackets.
func bracket(s string) string {
s = strings.TrimSpace(s)
if s == "" {
return ""
}
return "[" + s + "]"
}
// spotHidden reports whether this status should stay off the panadapter.
//
// Read from the same cached palette as the colours — see spotColorFor — because
// it is consulted on every spot from every cluster.
// spotPriority ranks a spot for SmartSDR's own tie-breaker, 1 (highest) to 5.
//
// The panadapter has finite room: spots close in frequency are stacked behind a
// "+" and only one of them is drawn. The radio chooses by priority — so an
// entity never worked was disappearing behind three stations already in the
// log, simply because OpsLog never said which was worth the space.
//
// The tiers are the operator's own: the entity never worked first, then the
// pieces of one already worked (band, mode, slot), then the reference hunts
// (POTA, SOTA, county, prefix), and everything else last. My own callsign rides
// at the top with the first — it is how a multi-op sees where it already is.
func spotPriority(status string) int {
switch status {
case "new", "my-call":
return 1
case "new-band-mode", "new-band", "new-mode", "new-slot":
return 2
case "new-pota", "new-sota", "new-county", "new-pfx":
return 3
default:
return 5
}
}
func (a *App) spotHidden(status string) bool {
a.spotColorsMu.Lock()
if a.spotColorsCache == nil {
c := a.GetSpotColors()
a.spotColorsCache = &c
}
p := a.spotColorsCache
a.spotColorsMu.Unlock()
if c, ok := p.Colors[status]; ok {
return c.Hide
}
return false
}