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:
@@ -23,13 +23,14 @@ import { cn } from '@/lib/utils';
|
|||||||
// ─────────────────────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type Bucket = { key: string; count: number };
|
type Bucket = { key: string; count: number };
|
||||||
|
type BandCat = { band: string; cw: number; phone: number; data: number; total: number };
|
||||||
type Gap = { start: string; end: string; minutes: number };
|
type Gap = { start: string; end: string; minutes: number };
|
||||||
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
type ContestRun = { id: string; year: number; count: number; start: string; end: string };
|
||||||
type Stats = {
|
type Stats = {
|
||||||
total: number; unique_calls: number; entities: number; continents: number;
|
total: number; unique_calls: number; entities: number; continents: number;
|
||||||
first_qso: string; last_qso: string;
|
first_qso: string; last_qso: string;
|
||||||
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
confirmed_lotw: number; confirmed_eqsl: number; confirmed_qsl: number; confirmed_any: number;
|
||||||
by_mode: Bucket[]; by_band: Bucket[]; by_operator: Bucket[]; by_station: Bucket[];
|
by_mode: Bucket[]; by_band: Bucket[]; by_band_category: BandCat[]; by_operator: Bucket[]; by_station: Bucket[];
|
||||||
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
by_continent: Bucket[]; top_entities: Bucket[]; by_year: Bucket[]; by_month: Bucket[];
|
||||||
// Period / contest metrics.
|
// Period / contest metrics.
|
||||||
window_start: string; window_end: string; window_hours: number;
|
window_start: string; window_end: string; window_hours: number;
|
||||||
@@ -157,6 +158,47 @@ function VBars({ data, empty, height = 150, showValues, colorful }: { data: Buck
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-band mode split (CW / phone / data) as stacked vertical bars. The three
|
||||||
|
// categories are the subject → categorical colour in a fixed order, matching the
|
||||||
|
// mode colours used elsewhere (CW gold, phone green, data blue).
|
||||||
|
const BAND_SPLIT = [
|
||||||
|
{ key: 'cw', label: 'CW', color: 'var(--chart-3)' },
|
||||||
|
{ key: 'phone', label: 'Phone', color: 'var(--chart-2)' },
|
||||||
|
{ key: 'data', label: 'Data', color: 'var(--chart-1)' },
|
||||||
|
] as const;
|
||||||
|
function StackedBandBars({ data, empty, height = 150 }: { data: BandCat[]; empty: string; height?: number }) {
|
||||||
|
if (!data || data.length === 0) return <p className="text-[11px] text-muted-foreground italic py-4 text-center">{empty}</p>;
|
||||||
|
const peak = Math.max(1, ...data.map((d) => d.total));
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-end gap-[3px] min-w-0" style={{ height }}>
|
||||||
|
{data.map((d) => (
|
||||||
|
<div key={d.band} className="flex-1 min-w-0 flex flex-col items-center justify-end h-full group"
|
||||||
|
title={`${d.band} — CW ${nf(d.cw)} · Phone ${nf(d.phone)} · Data ${nf(d.data)} (${nf(d.total)})`}>
|
||||||
|
<span className="text-[9px] font-semibold mb-0.5 tabular-nums text-foreground">{nf(d.total)}</span>
|
||||||
|
<div className="w-full flex flex-col justify-end rounded-t-[4px] overflow-hidden"
|
||||||
|
style={{ height: `${Math.max(2, (d.total / peak) * 100)}%` }}>
|
||||||
|
{BAND_SPLIT.map((s) => {
|
||||||
|
const v = d[s.key];
|
||||||
|
if (!v) return null;
|
||||||
|
return <div key={s.key} style={{ flexGrow: v, flexBasis: 0, minHeight: 1, background: s.color }} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<span className="mt-1 h-3 text-[9px] text-muted-foreground w-full text-center whitespace-nowrap">{d.band}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 flex items-center justify-center gap-3 text-[10px] text-muted-foreground">
|
||||||
|
{BAND_SPLIT.map((s) => (
|
||||||
|
<span key={s.key} className="inline-flex items-center gap-1">
|
||||||
|
<span className="size-2.5 rounded-[3px]" style={{ background: s.color }} /> {s.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
// ── Contest rate, stacked by operator ────────────────────────────────────────
|
||||||
// The categories (the operators) ARE the subject, so this is categorical colour,
|
// The categories (the operators) ARE the subject, so this is categorical colour,
|
||||||
// in the FIXED order the backend sends (busiest first). An operator therefore
|
// in the FIXED order the backend sends (busiest first). An operator therefore
|
||||||
@@ -524,7 +566,7 @@ export function StatsPanel() {
|
|||||||
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
const arr = (v: any) => (Array.isArray(v) ? v : []);
|
||||||
setStats({
|
setStats({
|
||||||
...raw,
|
...raw,
|
||||||
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_operator: arr(raw.by_operator),
|
by_mode: arr(raw.by_mode), by_band: arr(raw.by_band), by_band_category: arr(raw.by_band_category), by_operator: arr(raw.by_operator),
|
||||||
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
by_station: arr(raw.by_station), by_continent: arr(raw.by_continent),
|
||||||
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
top_entities: arr(raw.top_entities), by_year: arr(raw.by_year), by_month: arr(raw.by_month),
|
||||||
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
rate: arr(raw.rate), gaps: arr(raw.gaps),
|
||||||
@@ -740,6 +782,9 @@ export function StatsPanel() {
|
|||||||
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
<Card title={t('stats.byMode')} accent="var(--chart-2)">
|
||||||
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
<HBars data={stats.by_mode} max={8} empty={empty} colorful />
|
||||||
</Card>
|
</Card>
|
||||||
|
<Card title={t('stats.bandSplit')} sub={t('stats.bandSplitSub')} className="lg:col-span-2" accent="var(--chart-3)">
|
||||||
|
<StackedBandBars data={stats.by_band_category} empty={empty} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
<Card title={t('stats.overTime')} sub={t('stats.overTimeSub')} className="lg:col-span-2" accent="var(--chart-1)">
|
||||||
<AreaTrend data={stats.by_month} empty={empty} />
|
<AreaTrend data={stats.by_month} empty={empty} />
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const en: Dict = {
|
|||||||
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
'stats.noData': 'No data', 'stats.charts': 'Charts', 'stats.table': 'Table', 'stats.refresh': 'Refresh',
|
||||||
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
'stats.qsos': 'QSOs', 'stats.uniqueCalls': 'Unique callsigns', 'stats.entities': 'Entities',
|
||||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmed',
|
||||||
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size',
|
'stats.byBand': 'By band', 'stats.byBandSub': 'In band-plan order, not by size', 'stats.bandSplit': 'Mode split by band', 'stats.bandSplitSub': 'CW / phone / data per band',
|
||||||
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
'stats.byMode': 'By mode', 'stats.byOperator': 'By operator',
|
||||||
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
'stats.byOperatorSub': '“—” = logged by the station owner (no OPERATOR set)',
|
||||||
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
'stats.byStation': 'By station callsign', 'stats.byContinent': 'By continent',
|
||||||
@@ -374,7 +374,7 @@ const fr: Dict = {
|
|||||||
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
'stats.noData': 'Aucune donnée', 'stats.charts': 'Graphiques', 'stats.table': 'Tableau', 'stats.refresh': 'Rafraîchir',
|
||||||
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
'stats.qsos': 'QSO', 'stats.uniqueCalls': 'Indicatifs uniques', 'stats.entities': 'Entités',
|
||||||
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
'stats.continents': 'Continents', 'stats.confirmed': 'Confirmés',
|
||||||
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille",
|
'stats.byBand': 'Par bande', 'stats.byBandSub': "Dans l'ordre du plan de bande, pas par taille", 'stats.bandSplit': 'Répartition des modes par bande', 'stats.bandSplitSub': 'CW / phone / data par bande',
|
||||||
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
'stats.byMode': 'Par mode', 'stats.byOperator': 'Par opérateur',
|
||||||
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
'stats.byOperatorSub': '« — » = loggé par le titulaire (pas d’OPERATOR renseigné)',
|
||||||
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
'stats.byStation': 'Par indicatif de station', 'stats.byContinent': 'Par continent',
|
||||||
|
|||||||
@@ -3411,6 +3411,26 @@ export namespace qslcard {
|
|||||||
|
|
||||||
export namespace qso {
|
export namespace qso {
|
||||||
|
|
||||||
|
export class BandCategory {
|
||||||
|
band: string;
|
||||||
|
cw: number;
|
||||||
|
phone: number;
|
||||||
|
data: number;
|
||||||
|
total: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new BandCategory(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.band = source["band"];
|
||||||
|
this.cw = source["cw"];
|
||||||
|
this.phone = source["phone"];
|
||||||
|
this.data = source["data"];
|
||||||
|
this.total = source["total"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class BandMode {
|
export class BandMode {
|
||||||
band: string;
|
band: string;
|
||||||
mode: string;
|
mode: string;
|
||||||
@@ -3900,6 +3920,7 @@ export namespace qso {
|
|||||||
confirmed_any: number;
|
confirmed_any: number;
|
||||||
by_mode: Bucket[];
|
by_mode: Bucket[];
|
||||||
by_band: Bucket[];
|
by_band: Bucket[];
|
||||||
|
by_band_category: BandCategory[];
|
||||||
by_operator: Bucket[];
|
by_operator: Bucket[];
|
||||||
by_station: Bucket[];
|
by_station: Bucket[];
|
||||||
by_continent: Bucket[];
|
by_continent: Bucket[];
|
||||||
@@ -3939,6 +3960,7 @@ export namespace qso {
|
|||||||
this.confirmed_any = source["confirmed_any"];
|
this.confirmed_any = source["confirmed_any"];
|
||||||
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
this.by_mode = this.convertValues(source["by_mode"], Bucket);
|
||||||
this.by_band = this.convertValues(source["by_band"], Bucket);
|
this.by_band = this.convertValues(source["by_band"], Bucket);
|
||||||
|
this.by_band_category = this.convertValues(source["by_band_category"], BandCategory);
|
||||||
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
this.by_operator = this.convertValues(source["by_operator"], Bucket);
|
||||||
this.by_station = this.convertValues(source["by_station"], Bucket);
|
this.by_station = this.convertValues(source["by_station"], Bucket);
|
||||||
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
this.by_continent = this.convertValues(source["by_continent"], Bucket);
|
||||||
|
|||||||
+49
-2
@@ -24,6 +24,30 @@ type Bucket struct {
|
|||||||
Count int `json:"count"`
|
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
|
// 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.
|
// are the expensive minutes: they are where the score went.
|
||||||
type Gap struct {
|
type Gap struct {
|
||||||
@@ -129,8 +153,9 @@ type Stats struct {
|
|||||||
ConfirmedAny int `json:"confirmed_any"`
|
ConfirmedAny int `json:"confirmed_any"`
|
||||||
|
|
||||||
// Breakdowns, each sorted most → least (bands keep frequency order).
|
// Breakdowns, each sorted most → least (bands keep frequency order).
|
||||||
ByMode []Bucket `json:"by_mode"`
|
ByMode []Bucket `json:"by_mode"`
|
||||||
ByBand []Bucket `json:"by_band"`
|
ByBand []Bucket `json:"by_band"`
|
||||||
|
ByBandCategory []BandCategory `json:"by_band_category"` // per band: CW / phone / data split
|
||||||
ByOperator []Bucket `json:"by_operator"`
|
ByOperator []Bucket `json:"by_operator"`
|
||||||
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
ByStation []Bucket `json:"by_station"` // station_callsign (the call put on the air)
|
||||||
ByContinent []Bucket `json:"by_continent"`
|
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{}{}
|
entities = map[int]struct{}{}
|
||||||
modeC = map[string]int{}
|
modeC = map[string]int{}
|
||||||
bandC = map[string]int{}
|
bandC = map[string]int{}
|
||||||
|
bandCat = map[string]*BandCategory{} // per band: cw/phone/data
|
||||||
opC = map[string]int{}
|
opC = map[string]int{}
|
||||||
stationC = map[string]int{}
|
stationC = map[string]int{}
|
||||||
contC = 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 != "" {
|
if b := strings.ToLower(strings.TrimSpace(band.String)); b != "" {
|
||||||
bandC[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).
|
// op was resolved above (with the operator filter applied).
|
||||||
opC[op]++
|
opC[op]++
|
||||||
@@ -414,6 +454,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
}
|
}
|
||||||
return a < b
|
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
|
// 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
|
// 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
|
// chart would invent activity that never happened. A gap in the log is real
|
||||||
|
|||||||
Reference in New Issue
Block a user