feat(bandmap,dxped): drop the ctrl+wheel zoom, badge the news

ctrl+wheel is the WINDOW zoom everywhere else in OpsLog, and one gesture
that resizes the whole app over one panel and one band map over another
is a gesture nobody can trust. The + and - buttons keep the zoom, and
the footer hint stops advertising what is gone.

The DX-World headlines now carry a chase badge like the announcements —
but ENTITY-LEVEL only. A headline names no band, and asking the slot
question without one answers 'new band' for every entity ever worked:
the one mistake that costs a QSO. So the news says NEW DXCC or worked,
and the sharper verdicts stay where the bands and modes are known.
This commit is contained in:
2026-08-31 18:57:44 +02:00
parent 4a017f6290
commit 2b6f1ba9d7
7 changed files with 119 additions and 53 deletions
+62 -3
View File
@@ -119,9 +119,22 @@ func (a *App) judgeDXpeditions(list []DXpedition) {
}
}
// DXNews is one DX-World post plus what its callsigns are worth here.
type DXNews struct {
dxped.News
// Status is ENTITY-LEVEL only, and deliberately so: a headline announces no
// bands or modes, and asking the slot question without them would answer
// "new band" for every entity ever worked — the one mistake that costs a
// QSO. So it says "new" (entity never worked) or "worked", nothing finer;
// the announcements pane, which HAS the bands and modes, is where the
// sharper verdicts belong.
Status string `json:"status_chase"`
Unconfirmed bool `json:"unconfirmed"`
}
// GetDXWorldNews returns the DX-World headlines, with the callsigns mined out
// of each one so the reader can act on them.
func (a *App) GetDXWorldNews() ([]dxped.News, error) {
// of each one and judged against the log.
func (a *App) GetDXWorldNews() ([]DXNews, error) {
if a.dxped == nil {
a.dxped = dxped.New()
}
@@ -132,7 +145,53 @@ func (a *App) GetDXWorldNews() ([]dxped.News, error) {
return nil, err
}
}
return news, nil
out := make([]DXNews, 0, len(news))
for _, n := range news {
out = append(out, DXNews{News: n})
}
a.judgeNews(out)
return out, nil
}
// judgeNews fills the entity-level verdict for each headline.
//
// One query per callsign with NO band and NO mode: the only verdict that
// means anything in that state is "this entity has never been worked". Every
// finer answer the spot judge can give without a band is an artefact of the
// missing band, so it is flattened to "worked" rather than repeated.
func (a *App) judgeNews(list []DXNews) {
if a.qso == nil || len(list) == 0 {
return
}
var queries []SpotQuery
var owners []int
for i, n := range list {
for _, call := range n.Calls {
queries = append(queries, SpotQuery{Call: call})
owners = append(owners, i)
}
}
if len(queries) == 0 {
return
}
res := a.ClusterSpotStatuses(queries)
for i, r := range res {
if i >= len(owners) {
break
}
row := owners[i]
st := ""
switch {
case r.Status == "new":
st = "new"
case r.Status != "":
st = "worked"
}
if chaseRank[st] > chaseRank[list[row].Status] {
list[row].Status = st
list[row].Unconfirmed = r.UnconfStatus
}
}
}
// RefreshDXpeditions drops both caches so the next read goes to the network.
+6 -2
View File
@@ -13,7 +13,9 @@
"Watchlist: adding or removing a callsign now raises the same kind of notification as a new version, instead of a message inside the watchlist page — a call can be added from the cluster or the DXpeditions tab, where that message was never seen.",
"QSO editor, QSL Info: a HamQTH channel and its row in the status table — sent only, the received column showing a dash since the site publishes no confirmations.",
"QSO editor, QSL Info: the confirmation channels are listed paper QSL and LoTW first — the two that carry an ARRL award — then alphabetically, in both the picker and the status table.",
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions."
"Band map: a chevron in the footer folds the colour legend away and brings it back — four lines of a short screen, remembered between sessions.",
"Band map: ctrl+wheel no longer zooms it — that gesture is the window zoom everywhere else in OpsLog. The + and buttons keep the zoom.",
"DXpeditions: the news headlines carry a chase badge too — NEW DXCC or worked. Entity-level only, since a headline names no band: the finer verdicts stay in the announcements pane, which knows the bands and modes."
],
"fr": [
"Changer de base de réglages naffiche plus « OpsLog is already running » : la relance automatique attend désormais que linstance qui se ferme libère son verrou au lieu de la prendre de vitesse.",
@@ -26,7 +28,9 @@
"Watchlist : ajouter ou retirer un indicatif déclenche désormais une notification du même type que celle des nouvelles versions, au lieu dun message dans la page watchlist — un indicatif peut être ajouté depuis le cluster ou longlet DXpéditions, où ce message n’était jamais vu.",
"Éditeur de QSO, onglet QSL : un canal HamQTH et sa ligne dans le tableau des statuts — envoi seulement, la colonne reçu affichant un tiret puisque le site ne publie aucune confirmation.",
"Éditeur de QSO, onglet QSL : les canaux de confirmation sont classés QSL papier puis LoTW — les deux qui comptent pour un diplôme ARRL — puis par ordre alphabétique, dans le sélecteur comme dans le tableau.",
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé dune session à lautre."
"Band map : un chevron dans le pied de page replie la légende des couleurs et la fait revenir — quatre lignes gagnées sur un petit écran, mémorisé dune session à lautre.",
"Band map : ctrl+molette ne zoome plus la carte — ce geste est le zoom de la fenêtre partout ailleurs dans OpsLog. Les boutons + et gardent le zoom.",
"DXpéditions : les actualités portent aussi un badge de chasse — NOUVEAU DXCC ou contacté. Au niveau entité seulement, une manchette ne nommant aucune bande : les verdicts plus fins restent dans le volet des annonces, qui connaît bandes et modes."
]
},
{
+4 -13
View File
@@ -471,19 +471,10 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [band, containerH, currentFreqHz, range, lo, hi, pxPerKHz, fitToBand]);
useEffect(() => {
const el = scrollerRef.current;
if (!el) return;
const onWheel = (e: WheelEvent) => {
if (!range) return;
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
changeZoom(e.deltaY > 0 ? -1 : 1);
}
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [range]);
// No ctrl+wheel zoom here any more: ctrl+wheel is the WINDOW zoom everywhere
// else in OpsLog (View ▸ Zoom in/out), and one gesture that resizes the whole
// app over one panel and one band map over another is a gesture nobody can
// trust. The + / buttons keep the zoom, deliberately and visibly.
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
@@ -26,6 +26,10 @@ type DXped = {
type News = {
title: string; link: string; pub_date: string; excerpt: string;
creator: string; image_url: string; tag: string; calls?: string[];
// Entity-level only — a headline names no band, so the backend answers
// 'new' or 'worked' and nothing finer.
status_chase?: string;
unconfirmed?: boolean;
};
// One badge per expedition, the strongest verdict winning — the same palette
@@ -180,6 +184,7 @@ export function DXpeditionsPanel() {
)}
{news.map((n, i) => {
const newsCalls = n.calls ?? [];
const newsBadge = CHASE_BADGE[n.status_chase ?? ''];
const newsAllWatched = newsCalls.length > 0 && newsCalls.every((c) => watched.has(c.toUpperCase()));
return (
<article key={`${n.link}-${i}`} className="rounded-md border border-border bg-muted/20 p-2">
@@ -193,6 +198,15 @@ export function DXpeditionsPanel() {
{n.tag && (
<span className="px-1 py-px rounded text-[9px] font-bold uppercase bg-primary/15 text-primary">{n.tag}</span>
)}
{newsBadge && (
<span className="px-1 py-px rounded text-[9px] font-bold uppercase tracking-wide border shrink-0"
title={n.unconfirmed ? t('dec.unconfTip') : undefined}
style={n.unconfirmed
? { color: newsBadge.colour, borderColor: newsBadge.colour, borderStyle: 'dashed', opacity: 0.6 }
: { color: newsBadge.colour, borderColor: newsBadge.colour }}>
{t(newsBadge.label)}
</span>
)}
<button type="button" onClick={() => n.link && BrowserOpenURL(n.link)}
className="text-xs font-medium text-left hover:underline">{n.title}</button>
</div>
+2 -2
View File
@@ -436,7 +436,7 @@ const en: Dict = {
'bmp.statusWorked': 'Entity worked on this band and mode', 'bmp.statusUnresolved': 'Entity not resolved', 'bmp.bandMap': 'Band map', 'bmp.notConfigured': 'Not configured for {band}.',
'bmp.zoomOut': 'Zoom out', 'bmp.zoomIn': 'Zoom in', 'bmp.scrollToRig': 'Scroll to current rig frequency', 'bmp.moveLeft': 'Move band map to the left', 'bmp.moveRight': 'Move band map to the right', 'bmp.hide': 'Hide band map',
'bmp.bandsLabel': 'Bands:', 'bmp.fit': 'FIT', 'bmp.ftx': 'FTx', 'bmp.hideFt': 'Hide FTx', 'bmp.hideFtTitle': 'Hide all digital (FT8/FT4/JS8/…) spots on every band map', 'bmp.fitBand': 'Fit to band', 'bmp.widthTip': 'Drag to resize — double-click to reset', 'bmp.fitTitle': 'Size each band map to show the whole band edge-to-edge',
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Entity worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.legendHide': 'Hide the legend', 'bmp.legendShow': 'Show the legend', 'bmp.footerHint': 'scroll · ctrl+wheel = zoom · ◎ = jump to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
'bmp.legendNewDxcc': 'New DXCC', 'bmp.legendNewBand': 'New band', 'bmp.legendNewSlot': 'New slot (mode)', 'bmp.openUnusual': 'unusual for the season', 'bmp.legendWorked': 'Entity worked', 'bmp.legendNewPota': 'New POTA', 'bmp.legendNewCounty': 'New county', 'bmp.legendWorkedCall': 'Callsign already worked', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Data', 'bmp.legendPhone': 'Phone', 'bmp.legendHide': 'Hide the legend', 'bmp.legendShow': 'Show the legend', 'bmp.footerHint': 'scroll · ◎ = go to rig', 'bmp.spotsHidden': '{n} FT8/FT4 spots hidden — top {max} kept (CW/SSB all shown)',
'frm.welcome': 'Welcome to OpsLog', 'frm.intro': 'Set up your station to start logging. These fields stamp every QSO and can be changed later in Preferences → Station Information (and per profile).',
'frm.callsign': 'Callsign', 'frm.locator': 'Locator', 'frm.operator': 'Operator', 'frm.operatorPh': 'same as callsign', 'frm.owner': 'Owner', 'frm.ownerPh': 'station owner callsign', 'frm.name': 'Name', 'frm.namePh': 'your first name',
'frm.awardRefs': 'Award reference lists', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — names & totals for those awards (optional, can take a minute).', 'frm.downloading': 'Downloading…', 'frm.reDownload': 'Re-download', 'frm.download': 'Download', 'frm.required': 'Callsign and locator are required.', 'frm.saving': 'Saving…', 'frm.startLogging': 'Start logging',
@@ -943,7 +943,7 @@ const fr: Dict = {
'bmp.statusWorked': 'Entité contactée sur cette bande et ce mode', 'bmp.statusUnresolved': 'Entité non résolue', 'bmp.bandMap': 'Carte de bande', 'bmp.notConfigured': 'Non configurée pour {band}.',
'bmp.zoomOut': 'Dézoomer', 'bmp.zoomIn': 'Zoomer', 'bmp.scrollToRig': 'Aller à la fréquence actuelle du poste', 'bmp.moveLeft': 'Déplacer la carte de bande à gauche', 'bmp.moveRight': 'Déplacer la carte de bande à droite', 'bmp.hide': 'Masquer la carte de bande',
'bmp.bandsLabel': 'Bandes :', 'bmp.fit': 'FIT', 'bmp.ftx': 'FTx', 'bmp.hideFt': 'Masquer FTx', 'bmp.hideFtTitle': 'Masquer tous les spots numériques (FT8/FT4/JS8/…) sur toutes les cartes', 'bmp.fitBand': 'Ajuster à la bande', 'bmp.widthTip': 'Glisser pour redimensionner — double-clic pour réinitialiser', 'bmp.fitTitle': 'Dimensionner chaque carte pour afficher toute la bande',
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Entité contactée', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.legendHide': 'Masquer la légende', 'bmp.legendShow': 'Afficher la légende', 'bmp.footerHint': 'défiler · ctrl+molette = zoom · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
'bmp.legendNewDxcc': 'Nouveau DXCC', 'bmp.legendNewBand': 'Nouvelle bande', 'bmp.legendNewSlot': 'Nouveau mode', 'bmp.openUnusual': 'inhabituel pour la saison', 'bmp.legendWorked': 'Entité contactée', 'bmp.legendNewPota': 'Nouveau POTA', 'bmp.legendNewCounty': 'Nouveau comté', 'bmp.legendWorkedCall': 'Indicatif déjà contacté', 'bmp.legendCW': 'CW', 'bmp.legendData': 'Numérique', 'bmp.legendPhone': 'Phonie', 'bmp.legendHide': 'Masquer la légende', 'bmp.legendShow': 'Afficher la légende', 'bmp.footerHint': 'défiler · ◎ = aller au poste', 'bmp.spotsHidden': '{n} spots FT8/FT4 masqués — {max} meilleurs conservés (CW/SSB tous affichés)',
'frm.welcome': 'Bienvenue dans OpsLog', 'frm.intro': 'Configure ta station pour commencer à logger. Ces champs sont inscrits sur chaque QSO et peuvent être modifiés plus tard dans Préférences → Informations station (et par profil).',
'frm.callsign': 'Indicatif', 'frm.locator': 'Locator', 'frm.operator': 'Opérateur', 'frm.operatorPh': "identique à l'indicatif", 'frm.owner': 'Propriétaire', 'frm.ownerPh': 'indicatif du propriétaire de la station', 'frm.name': 'Nom', 'frm.namePh': 'ton prénom',
'frm.awardRefs': 'Listes de références des diplômes', 'frm.awardRefsHint': 'IOTA · POTA · WWFF · SOTA — noms et totaux pour ces diplômes (optionnel, peut prendre une minute).', 'frm.downloading': 'Téléchargement…', 'frm.reDownload': 'Retélécharger', 'frm.download': 'Télécharger', 'frm.required': "L'indicatif et le locator sont obligatoires.", 'frm.saving': 'Enregistrement…', 'frm.startLogging': 'Commencer à logger',
+1 -2
View File
@@ -12,7 +12,6 @@ import {award} from '../models';
import {awardref} from '../models';
import {bandopen} from '../models';
import {cluster} from '../models';
import {dxped} from '../models';
import {extsvc} from '../models';
import {powergenius} from '../models';
import {pskr} from '../models';
@@ -477,7 +476,7 @@ export function GetDVKMessages():Promise<Array<main.DVKMessage>>;
export function GetDVKStatus():Promise<main.DVKStatus>;
export function GetDXWorldNews():Promise<Array<dxped.News>>;
export function GetDXWorldNews():Promise<Array<main.DXNews>>;
export function GetDXpeditions():Promise<Array<main.DXpedition>>;
+30 -31
View File
@@ -1466,37 +1466,6 @@ export namespace contest {
}
export namespace dxped {
export class News {
title: string;
link: string;
pub_date: string;
excerpt: string;
creator: string;
image_url: string;
tag: string;
calls: string[];
static createFrom(source: any = {}) {
return new News(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.title = source["title"];
this.link = source["link"];
this.pub_date = source["pub_date"];
this.excerpt = source["excerpt"];
this.creator = source["creator"];
this.image_url = source["image_url"];
this.tag = source["tag"];
this.calls = source["calls"];
}
}
}
export namespace extsvc {
export class ServiceConfig {
@@ -2705,6 +2674,36 @@ export namespace main {
this.rec_slot = source["rec_slot"];
}
}
export class DXNews {
title: string;
link: string;
pub_date: string;
excerpt: string;
creator: string;
image_url: string;
tag: string;
calls: string[];
status_chase: string;
unconfirmed: boolean;
static createFrom(source: any = {}) {
return new DXNews(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.title = source["title"];
this.link = source["link"];
this.pub_date = source["pub_date"];
this.excerpt = source["excerpt"];
this.creator = source["creator"];
this.image_url = source["image_url"];
this.tag = source["tag"];
this.calls = source["calls"];
this.status_chase = source["status_chase"];
this.unconfirmed = source["unconfirmed"];
}
}
export class DXpedition {
dxcc: string;
callsign: string;