feat: stats — per-band mode split (CW / phone / data) stacked bar chart
Adds Stats.ByBandCategory (per band: CW/phone/data counts, band-plan order) via a modeCategory() bucketing in stats.go, and a StackedBandBars chart in the Stats panel showing the split per band with a legend.
This commit is contained in:
+49
-2
@@ -24,6 +24,30 @@ type Bucket struct {
|
||||
Count int `json:"count"`
|
||||
}
|
||||
|
||||
// BandCategory is one band's QSO count split by mode category (CW / phone /
|
||||
// digital), for the per-band mode-split chart.
|
||||
type BandCategory struct {
|
||||
Band string `json:"band"`
|
||||
CW int `json:"cw"`
|
||||
Phone int `json:"phone"`
|
||||
Data int `json:"data"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// modeCategory buckets a mode into "cw", "phone" or "data" (digital). Voice modes
|
||||
// (SSB and the digital-voice family) count as phone; CW is CW; everything else is
|
||||
// data. Mirrors the frontend's mode colouring.
|
||||
func modeCategory(mode string) string {
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
return "cw"
|
||||
case "SSB", "USB", "LSB", "AM", "FM", "DV", "DIGITALVOICE", "FREEDV", "C4FM", "DSTAR", "FUSION":
|
||||
return "phone"
|
||||
default:
|
||||
return "data"
|
||||
}
|
||||
}
|
||||
|
||||
// Gap is a stretch with no QSO at all — the off-air periods. In a contest these
|
||||
// are the expensive minutes: they are where the score went.
|
||||
type Gap struct {
|
||||
@@ -129,8 +153,9 @@ type Stats struct {
|
||||
ConfirmedAny int `json:"confirmed_any"`
|
||||
|
||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||
ByMode []Bucket `json:"by_mode"`
|
||||
ByBand []Bucket `json:"by_band"`
|
||||
ByMode []Bucket `json:"by_mode"`
|
||||
ByBand []Bucket `json:"by_band"`
|
||||
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||
ByOperator []Bucket `json:"by_operator"`
|
||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||
ByContinent []Bucket `json:"by_continent"`
|
||||
@@ -268,6 +293,7 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
entities = map[int]struct{}{}
|
||||
modeC = map[string]int{}
|
||||
bandC = map[string]int{}
|
||||
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||
opC = map[string]int{}
|
||||
stationC = map[string]int{}
|
||||
contC = map[string]int{}
|
||||
@@ -338,6 +364,20 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
}
|
||||
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||
bandC[b]++
|
||||
bc := bandCat[b]
|
||||
if bc == nil {
|
||||
bc = &BandCategory{Band: b}
|
||||
bandCat[b] = bc
|
||||
}
|
||||
switch modeCategory(mode.String) {
|
||||
case "cw":
|
||||
bc.CW++
|
||||
case "phone":
|
||||
bc.Phone++
|
||||
default:
|
||||
bc.Data++
|
||||
}
|
||||
bc.Total++
|
||||
}
|
||||
// op was resolved above (with the operator filter applied).
|
||||
opC[op]++
|
||||
@@ -414,6 +454,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
||||
}
|
||||
return a < b
|
||||
})
|
||||
// Per-band CW/phone/data split, in the SAME band-plan order as ByBand.
|
||||
s.ByBandCategory = []BandCategory{}
|
||||
for _, b := range s.ByBand {
|
||||
if bc := bandCat[b.Key]; bc != nil {
|
||||
s.ByBandCategory = append(s.ByBandCategory, *bc)
|
||||
}
|
||||
}
|
||||
// The time axis must be CONTINUOUS. Emitting only the months that have QSOs
|
||||
// would place, say, 2012-08 next to 2022-01 as if they were consecutive — the
|
||||
// chart would invent activity that never happened. A gap in the log is real
|
||||
|
||||
Reference in New Issue
Block a user