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 + "]" }