226 lines
7.4 KiB
Go
226 lines
7.4 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"`
|
|
}
|
|
|
|
// 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{}
|
|
if hexARGB(v.Text) {
|
|
c.Text = strings.ToUpper(strings.TrimSpace(v.Text))
|
|
}
|
|
if hexARGB(v.Bg) {
|
|
c.Bg = strings.ToUpper(strings.TrimSpace(v.Bg))
|
|
}
|
|
if c.Text != "" || c.Bg != "" {
|
|
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 BAND+MODE"
|
|
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 COUNTY"
|
|
case "new-pfx":
|
|
return "NEW PFX"
|
|
case "my-call":
|
|
return "ME"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// spotComment appends the status tag to the cluster's own comment.
|
|
func spotComment(comment, status string) string {
|
|
c := strings.TrimSpace(comment)
|
|
tag := spotStatusTag(status)
|
|
if tag == "" {
|
|
return c
|
|
}
|
|
if c == "" {
|
|
return "[" + tag + "]"
|
|
}
|
|
return c + " [" + tag + "]"
|
|
}
|