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. 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 }