Compare commits

..
19 Commits
Author SHA1 Message Date
rouggy 1f667e4a4b chore: release v0.27.4 2026-08-30 22:24:05 +02:00
rouggy d5f7e290ab chore(changelog): the FTx menu, the FT Map and the map fixes enter the 0.27.4 block 2026-08-30 22:23:30 +02:00
rouggy f2ba798764 fix(maps): zooming stays centred on the grid and decode maps
The hard maxBounds added for the single-world look fought the zoom: with
the world smaller than the window the clamp dragged every zoom step into
a corner. The noWrap tiles already guarantee one world on their own, so
the bounds go — on both maps.
2026-08-30 22:18:27 +02:00
rouggy d7ee87f22d feat(ftx): the digital corner gets its own menu, and the watchlist learns to line up
An FTx menu between View and Tools gathers FT Decodes, FT Map and the
Grid squares map — three views of the same UDP feed that were buried in
Tools. And the watchlist spot rows swap the elastic country column for
fixed widths, so band, mode and frequency read as columns instead of
starting wherever each country name happened to end.
2026-08-30 22:14:40 +02:00
rouggy 2587e6bcfe style(ftmap): one world, themed ground — here and on every map
The FT map wrapped the planet into a row of copies and showed Leaflet's
hard-coded grey beyond it. noWrap tiles inside hard bounds now, like the
grid-square map already did — and the container background override moves to
style.css so EVERY map's surround follows the theme's card surface instead
of #ddd (the grid map's per-div fix was losing to leaflet.css specificity).
2026-08-30 22:05:25 +02:00
rouggy 755392e0fa feat(ftmap): the live decode feed as a world map — feature branch
A new FT Map tab (Tools menu, beside FT decodes): every station decoded in
the last half hour drawn as a great-circle arc from the operator's own
square, coloured by band the way PSK Reporter taught everyone to read it,
fading with age, one arc per call (its freshest sighting), a tooltip with
call/grid/SNR, the MainMap basemap picker and a legend of only the bands on
screen.

Performance was the open question and it is answered structurally: the panel
mounts only while its tab is ACTIVE (zero cost otherwise), the map renders
canvas (one element, not one node per arc), arcs are capped at 300, and
redraws follow the decode list — every period, not every frame.
2026-08-30 21:54:00 +02:00
rouggy a9fb428a0a fix(chase): band/mode/slot needs finally dim — 'worked' is not ''
spotEntityStatus says 'worked' when every grain is in the log; the
unconfirmed check compared its all-ledger verdict against the empty string,
so no band/mode/slot need ever dimmed however many unconfirmed QSOs stood
behind it — only the entity-level NEW, tested by map presence, behaved. The
check now runs for exactly the four needy statuses and asks for 'worked'.
2026-08-30 21:43:47 +02:00
rouggy bbb485b9cf fix(chase): the unconfirmed entity badge is visible
Dimming the filled chip to a transparent background left its light
foreground text on nothing — an invisible badge in the row. Unconfirmed now
renders in the category's own hue, dashed border and text, like the extras
beside it.
2026-08-30 21:40:26 +02:00
rouggy 3a94a69be3 style(chase): no question marks — the dashed dimmed chip says it alone 2026-08-30 21:38:14 +02:00
rouggy e4028f4bcd fix(chase): switching the rules invalidates every resolved verdict
The frontend caches statuses and only asks about unknown keys, so flipping
the chase mode left half the screen judged under the old rules until a
restart — which read as the mode not working. The backend now broadcasts an
invalidation and the panels re-ask as rows repaint. The entity badges also
say their name with a question mark, dashed, when the need is only a missing
QSL — the 50% opacity was invisible on a filled chip.
2026-08-30 21:37:31 +02:00
rouggy 7c10517bf5 fix(chase): each unconfirmed badge keeps its own name; the block moves up
The unconf label was hard-coded to GRID? for every category — a prefix
worked-but-unconfirmed showed as a second grid badge beside the real one,
which read as impossible. Each badge now says its own name plus a question
mark. And the global Chase block moves above the Chase POTA/SOTA/grid
checkboxes, where its operator expected the master switch.
2026-08-30 21:28:52 +02:00
rouggy cd4e5b1117 i18n: the global chase block's labels, dropped by the failed batch edit 2026-08-30 21:23:17 +02:00
rouggy a13ac7d917 test: the bulk-edit guard learns the integer My-station fields
my_dxcc/my_cq_zone/my_itu_zone go through BulkSetIntField, not the string
column map — the guard flagged them as unwritable ever since they were
added, which is the drift it exists to catch, one package over.
2026-08-30 21:03:03 +02:00
rouggy 4e28098a4e feat(chase): the global hunt — new, or new plus unconfirmed, every category
The gap between worked and confirmed becomes visible everywhere. A Chase
setting on the DX Cluster page picks the mode; in new-plus-unconfirmed the
verdicts for DXCC/band/mode/slot, prefix, county and state are judged against
CONFIRMED-only ledgers built from the operator's chosen sources (LoTW, card,
eQSL, QRZ.com — HRDLog has no confirmation field in ADIF to offer), and a
need that exists only because a QSL never arrived is flagged Unconf: the
cluster and decode badges render it dimmed, the convention the grid hunt
introduced. The grid's own Chase selector folds into the global one; its
square-matching scope stays where it was, being grid-specific. One snapshot
key change rebuilds the cache when any of it flips.
2026-08-30 21:02:34 +02:00
rouggy 52e98a71f4 feat(decodes): NEW STATE — badge and filter
The WAS gap made visible: a worked-states set joins the cluster status cache
(distinct states over the three US entities), the status entries carry a
NewState flag wherever the state resolves (log county first, ULS licence
second), and the decode panel gains the badge in its status column plus the
filter chip alongside NEW/BAND/MODE/GRID. Colour from the shared marker
palette, so the cluster can adopt it later without a second hue.
2026-08-30 20:44:58 +02:00
rouggy 81ff6a9b0e style(decodes): a gap between the state badge and its name 2026-08-30 20:38:50 +02:00
rouggy be52a23868 feat(decodes): a State column — badge plus full name
Between the locator and the country, per its operator: the two-letter badge
anchors the eye and the spelled-out name answers it — 'SD' alone is a quiz
for a European. The status-cell badge from the first pass moves here.
2026-08-30 20:36:45 +02:00
rouggy fc1561804c style(decodes): the US state is a proper badge
Bigger and boxed, per its operator — the plain grey text vanished into the
column it was meant to anchor.
2026-08-30 20:34:02 +02:00
rouggy 1288b3f998 feat(decodes): the two-letter US state in the status column
Plain text, not a badge — it is a fact, not an action — placed first so a
WAS chaser can scan the column edge. Shown whenever the status cache knows
the state (log county data or the licence address).
2026-08-30 20:31:05 +02:00
19 changed files with 653 additions and 69 deletions
+149 -8
View File
@@ -159,6 +159,8 @@ const (
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
keyChaseMode = "chase.mode" // "new" | "new_unconfirmed" — the global hunt, every category
keyChaseConfirm = "chase.confirm" // CSV of confirmation sources: lotw,card,eqsl,qrz
keyAudioMonitorOn = "audio.monitor.on" // play the network RX audio through the Listening device (the stream itself stays open for the recorder either way)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
@@ -19821,6 +19823,16 @@ type SpotStatus struct {
// NewCounty already has them in hand.
County string `json:"county,omitempty"`
State string `json:"state,omitempty"`
// NewState says this US state has never been worked — the WAS gap the
// decode panel's filter chases. Orthogonal to Status, like NewCounty.
NewState bool `json:"new_state"`
// The Unconf flags say the matching need exists ONLY because the contact
// was never confirmed (under the operator's chosen QSL sources): a QSL to
// chase, not a QSO to make. The badges render them dashed, the grid way.
UnconfStatus bool `json:"unconf_status,omitempty"`
UnconfPfx bool `json:"unconf_pfx,omitempty"`
UnconfCty bool `json:"unconf_cty,omitempty"`
UnconfState bool `json:"unconf_state,omitempty"`
NewPOTA bool `json:"new_pota"`
// Grid is the 4-character square this station announced in a CQ on the UDP
// link, and NewGrid says that square has never been worked. Both are empty /
@@ -19869,6 +19881,17 @@ type clusterStatusCache struct {
workedCallSlots map[string]struct{}
workedCallSlotsDig map[string]struct{} // same set, digital modes folded to DIG // nil unless the "same slot" option is on
workedCounties map[string]struct{}
workedStates map[string]struct{}
// The CONFIRMED-only ledgers, built when the global hunt is
// "new + unconfirmed": verdicts are judged against these, and a need that
// exists only because a contact was never confirmed is flagged Unconf so
// its badge can say "a QSL to chase", not "a QSO to make".
chaseUnconf bool
chaseKey string
entitiesConf map[int]*qso.EntitySlot
workedPfxConf map[string]struct{}
workedCountiesConf map[string]struct{}
workedStatesConf map[string]struct{}
// callCounties holds callsign → "STATE,County" for stations already logged
// with a county, so a spot shows the county the entry panel showed rather
// than the one derived from the licence ZIP. See qso.CallCounties.
@@ -19889,6 +19912,50 @@ type clusterStatusCache struct {
slotHighlight bool // (same: the slot index is built for either)
}
// ChaseSettings is the global hunt: does "worked but never confirmed" still
// count as something to chase, and which QSL systems count as confirmation.
type ChaseSettings struct {
Mode string `json:"mode"` // "new" | "new_unconfirmed"
Sources []string `json:"sources"` // subset of lotw,card,eqsl,qrz
}
// GetChaseSettings returns the global hunt settings.
func (a *App) GetChaseSettings() ChaseSettings {
mode := a.settingOr(keyChaseMode, "new")
if mode != "new_unconfirmed" {
mode = "new"
}
csv := a.settingOr(keyChaseConfirm, "lotw,card,eqsl")
var src []string
for _, part := range strings.Split(csv, ",") {
if part = strings.TrimSpace(part); part != "" {
src = append(src, part)
}
}
return ChaseSettings{Mode: mode, Sources: src}
}
// SaveChaseSettings stores them and drops the status snapshot so the next
// batch is judged under the new rules.
func (a *App) SaveChaseSettings(cs ChaseSettings) error {
mode := cs.Mode
if mode != "new_unconfirmed" {
mode = "new"
}
a.setSetting(keyChaseMode, mode)
a.setSetting(keyChaseConfirm, strings.Join(cs.Sources, ","))
a.clusterStatusMu.Lock()
a.clusterStatusIdx = nil
a.clusterStatusMu.Unlock()
// The frontend caches resolved verdicts and only asks about unknown keys —
// without this it kept judging half the screen under the OLD rules until a
// restart, which read as the new mode simply not working.
if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "spotstatus:invalidate")
}
return nil
}
// clusterStatusMaps returns the cached worked-index snapshot, building it once
// per logbook change (invalidated by invalidateAwardStats) or when a setting
// that shapes the maps flips. This turns the per-batch full-logbook scans into
@@ -19901,16 +19968,23 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// holding the status mutex across that is how two subsystems deadlock.
gs := a.GetGridScopeSettings()
gridScopeNow := lookupGridScope(gs.Scope)
gridHuntNow := gs.Hunt
// The grid hunt FOLLOWS the global chase mode now — the per-grid selector
// grew into the global option, and two switches for one idea is one too
// many. (Same vocabulary: "new" / "new_unconfirmed".)
chase := a.GetChaseSettings()
gridHuntNow := chase.Mode
chaseKeyNow := chase.Mode + "|" + strings.Join(chase.Sources, ",")
a.clusterStatusMu.Lock()
defer a.clusterStatusMu.Unlock()
if c := a.clusterStatusIdx; c != nil && c.groupDigital == groupDigital && c.sameSlot == sameSlot &&
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow {
c.slotHighlight == slotHighlight && c.gridScope == gridScopeNow && c.gridHunt == gridHuntNow &&
c.chaseKey == chaseKeyNow {
return c
}
c := &clusterStatusCache{
groupDigital: groupDigital, sameSlot: sameSlot, slotHighlight: slotHighlight,
gridScope: gridScopeNow, gridHunt: gridHuntNow,
chaseUnconf: chase.Mode == "new_unconfirmed", chaseKey: chaseKeyNow,
}
if a.qso == nil {
a.clusterStatusIdx = c
@@ -19967,6 +20041,7 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// Orthogonal dimensions: worked US counties (for the ULS callsign→county
// lookup) and worked POTA parks.
c.workedCounties, _ = a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
c.workedStates, _ = a.qso.WorkedStateKeys(a.ctx)
c.callCounties, _ = a.qso.CallCounties(a.ctx)
c.workedPOTA, _ = a.qso.WorkedPOTARefs(a.ctx)
// One more DISTINCT scan when the snapshot is rebuilt, then pure map lookups
@@ -19977,6 +20052,19 @@ func (a *App) clusterStatusMaps() *clusterStatusCache {
// extra query. Derived rather than read from the stored PFX column: that
// column is only filled when an import supplied it, and deriving keeps this
// in step with the WPX award, which does the same thing.
if c.chaseUnconf {
pred := qso.ConfirmSourcesPredicate(chase.Sources)
c.entitiesConf, _ = a.qso.EntitySlotMapPred(a.ctx, keyFor, c.normMode, pred)
c.workedCountiesConf, _ = a.qso.WorkedCountyKeysPred(a.ctx, award.USCountyKey, pred)
c.workedStatesConf, _ = a.qso.WorkedStateKeysPred(a.ctx, pred)
confCalls, _ := a.qso.WorkedCallsignsPred(a.ctx, pred)
c.workedPfxConf = make(map[string]struct{}, len(confCalls))
for call := range confCalls {
if pfx := award.WPXPrefix(call); pfx != "" {
c.workedPfxConf[pfx] = struct{}{}
}
}
}
c.workedPfx = make(map[string]struct{}, len(c.workedCalls))
for call := range c.workedCalls {
if p := award.WPXPrefix(call); p != "" {
@@ -20302,6 +20390,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
workedCounties := idx.workedCounties
workedPOTA := idx.workedPOTA
workedPfx := idx.workedPfx
// The hunt decides the ledger: "new + unconfirmed" judges every category
// against the CONFIRMED sets, and the all-QSO sets then say whether a need
// is a fresh one or a missing QSL (the Unconf flags).
judgeEntities, judgePfx, judgeCounties, judgeStates := entities, workedPfx, workedCounties, idx.workedStates
if idx.chaseUnconf {
judgeEntities, judgePfx, judgeCounties, judgeStates = idx.entitiesConf, idx.workedPfxConf, idx.workedCountiesConf, idx.workedStatesConf
}
normMode := idx.normMode
sameSlot := idx.sameSlot
for i, q := range spots {
@@ -20345,8 +20440,11 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
// NEW PFX: the spot's CQ WPX prefix, never worked before.
if p := award.WPXPrefix(q.Call); p != "" {
out[i].Pfx = p
if _, done := workedPfx[p]; !done {
if _, done := judgePfx[p]; !done {
out[i].NewPfx = true
if _, everWorked := workedPfx[p]; everWorked {
out[i].UnconfPfx = true
}
}
}
// NEW POTA: the spot's tagged park, never worked before.
@@ -20409,19 +20507,39 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if cnty, ok := idx.callCounties[q.Call]; ok {
st, name, _ := strings.Cut(cnty, ",")
out[i].State, out[i].County = st, name
// Logged means worked, so this can never be a new county — but say
// so through the same key the flag below uses, not by assumption.
if st != "" {
if _, done := judgeStates[strings.ToUpper(st)]; !done {
out[i].NewState = true
if _, ever := idx.workedStates[strings.ToUpper(st)]; ever {
out[i].UnconfState = true
}
}
}
if key := award.USCountyKey(st, name); key != "" {
if _, done := workedCounties[key]; !done {
if _, done := judgeCounties[key]; !done {
out[i].NewCounty = true
if _, ever := workedCounties[key]; ever {
out[i].UnconfCty = true
}
}
}
} else if a.uls != nil {
if loc, ok := a.uls.Resolve(q.Call); ok {
out[i].County, out[i].State = loc.County, loc.State
if loc.State != "" {
if _, done := judgeStates[strings.ToUpper(loc.State)]; !done {
out[i].NewState = true
if _, ever := idx.workedStates[strings.ToUpper(loc.State)]; ever {
out[i].UnconfState = true
}
}
}
if key := award.USCountyKey(loc.State, loc.County); key != "" {
if _, done := workedCounties[key]; !done {
if _, done := judgeCounties[key]; !done {
out[i].NewCounty = true
if _, ever := workedCounties[key]; ever {
out[i].UnconfCty = true
}
}
}
}
@@ -20449,9 +20567,12 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
if dxccNum == 0 {
continue // can't resolve the spot's entity number → don't guess
}
e, worked := entities[dxccNum]
e, worked := judgeEntities[dxccNum]
if !worked {
out[i].Status = "new"
if _, ever := entities[dxccNum]; ever {
out[i].UnconfStatus = true
}
continue
}
// The check mode goes through the same normaliser as the slot map
@@ -20466,6 +20587,26 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
_, haveSlot := e.Slots[out[i].Band][checkMode]
out[i].Status = spotEntityStatus(true, haveBand, haveMode, haveSlot, out[i].Mode)
// A need judged against the confirmed ledger that the all-QSO ledger
// says was already worked is a missing QSL, and its badge should read
// as one. Judged at the SAME grain as the status itself.
switch out[i].Status {
case "new-band-mode", "new-band", "new-mode", "new-slot":
if idx.chaseUnconf {
if eAll, ok := entities[dxccNum]; ok {
_, bAll := eAll.Bands[out[i].Band]
_, mAll := eAll.Modes[checkMode]
_, sAll := eAll.Slots[out[i].Band][checkMode]
// "worked", not "": that is what the verdict function says
// when every grain is in the log — the first version
// compared against empty and no band/mode/slot need ever
// dimmed, however many unconfirmed QSOs stood behind it.
if spotEntityStatus(true, bAll, mAll, sAll, out[i].Mode) == "worked" {
out[i].UnconfStatus = true
}
}
}
}
}
return out
}
+6
View File
@@ -33,6 +33,12 @@ func TestBulkEditFieldsAreWritable(t *testing.T) {
id := m[1]
// Handled before the column map: freq takes a numeric path (freq_hz +
// band together) and the extras live in extras_json.
// The integer My-station fields take their own numeric path
// (BulkSetIntField + bulkEditableIntCols in the repo), added after this
// guard was written — which is exactly the drift it exists to catch.
if id == "my_dxcc" || id == "my_cq_zone" || id == "my_itu_zone" {
continue
}
if id == "freq" || qso.IsBulkEditableExtra(id) {
continue
}
+22
View File
@@ -1,4 +1,26 @@
[
{
"version": "0.27.4",
"date": "",
"en": [
"FT decodes: a State column between the locator and the country — the two-letter badge plus the full name — for the WAS chasers.",
"FT decodes: a NEW STATE badge and filter — a US state never worked lights up in the status column, and the filter chip shows only those. The WAS chase is complete.",
"The hunt goes global: a “Chase” setting (DX Cluster page) decides for EVERY category — DXCC, band, mode, slot, prefix, county, state, grid — whether “worked but never confirmed” still counts as something to chase, judged against the confirmation sources you pick (LoTW, QSL card, eQSL, QRZ.com). Such needs show as dimmed badges: a QSL to chase, not a QSO to make. The grids own Chase selector folds into it.",
"New FTx menu gathering FT Decodes, the new FT Map and the Grid squares map.",
"FT Map: a world map of the live FTx decodes — great-circle arcs from your QTH to every station heard in the last 30 minutes, coloured by band, with the PSK-Reporter palette and a basemap picker.",
"Maps: one single world (no more side-by-side copies), the surround follows the theme colour, and zooming stays centred — on the FT Map and the Grid squares map.",
"Watchlist: fixed columns in the spot rows, so band, mode and frequency line up instead of drifting with the country name."
],
"fr": [
"FT decodes : une colonne État entre le locator et le pays — le badge deux lettres plus le nom complet — pour les chasseurs de WAS.",
"FT decodes : un badge et un filtre NOUVEL ÉTAT — un état US jamais contacté sallume dans la colonne statut, et la puce de filtre ne montre que ceux-là. La chasse WAS est complète.",
"La chasse devient globale : un réglage « Chasse » (page DX Cluster) décide pour TOUTES les catégories — DXCC, bande, mode, slot, préfixe, comté, état, grille — si « contacté mais jamais confirmé » reste à chasser, jugé selon les sources de confirmation choisies (LoTW, carte QSL, eQSL, QRZ.com). Ces besoins saffichent en badges atténués : une QSL à chasser, pas un QSO à faire. Le sélecteur Chasse des grilles fusionne dedans.",
"Nouveau menu FTx regroupant FT Decodes, la nouvelle FT Map et la carte Grid squares.",
"FT Map : une carte du monde des décodages FTx en direct — arcs orthodromiques depuis votre QTH vers chaque station entendue dans les 30 dernières minutes, colorés par bande, avec la palette PSK Reporter et un choix de fond de carte.",
"Cartes : un seul monde (fini les copies côte à côte), le pourtour suit la couleur du thème et le zoom reste centré — sur la FT Map et la carte Grid squares.",
"Watchlist : colonnes fixes dans les lignes de spots — bande, mode et fréquence s'alignent au lieu de dériver avec le nom du pays."
]
},
{
"version": "0.27.3",
"date": "",
+62 -4
View File
@@ -75,6 +75,7 @@ import { SendEQSLModal } from '@/components/qsl/SendEQSLModal';
import { AutoEQSL } from '@/components/qsl/AutoEQSL';
import { ConfirmDialog } from '@/components/ConfirmDialog';
import { SettingsModal } from '@/components/SettingsModal';
import { FTMapPanel } from '@/components/FTMapPanel';
import { FirstRunModal } from '@/components/FirstRunModal';
import { QSOEditModal } from '@/components/QSOEditModal';
import { BandMap } from '@/components/BandMap';
@@ -1313,6 +1314,17 @@ export default function App() {
writeUiPref('opslog.gridsTab', '0');
setActiveTab((t) => (t === 'grids' ? 'recent' : t));
}
const [ftmapTabOpen, setFtmapTabOpen] = useState(() => localStorage.getItem('opslog.ftmapTab') === '1');
function openFtmapTab() {
setFtmapTabOpen(true);
writeUiPref('opslog.ftmapTab', '1');
setActiveTab('ftmap');
}
function closeFtmapTab() {
setFtmapTabOpen(false);
writeUiPref('opslog.ftmapTab', '0');
setActiveTab((t) => (t === 'ftmap' ? 'recent' : t));
}
function openDecodesTab() {
setDecodesTabOpen(true);
writeUiPref('opslog.decodesTab', '1');
@@ -2088,7 +2100,7 @@ export default function App() {
// worked_slot must be carried explicitly like every other field: this map is
// assembled field by field, so a backend flag that nobody copies here simply
// never reaches the panels — silently, since the extra key is just dropped.
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; worked_slot?: boolean; new_county?: boolean; county?: string; state?: string; lotw?: boolean; spotter_continent?: string; grid?: string; new_grid?: boolean; new_state?: boolean; new_pota?: boolean; unconf_status?: boolean; unconf_pfx?: boolean; unconf_cty?: boolean; unconf_state?: boolean; new_pfx?: boolean; pfx?: string }>>({});
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
// still need resolving without re-subscribing the cluster:spot listener.
const spotStatusRef = useRef(spotStatus);
@@ -3639,6 +3651,11 @@ export default function App() {
worked_call: !!(r as any).worked_call,
worked_slot: !!(r as any).worked_slot,
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw, spotter_continent: (r as any).spotter_continent, grid: (r as any).grid, new_grid: !!(r as any).new_grid, county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx,
pfx: (r as any).pfx,
@@ -3745,6 +3762,11 @@ export default function App() {
new_county: !!(r as any).new_county, lotw: !!(r as any).lotw,
grid: (r as any).grid, new_grid: !!(r as any).new_grid,
county: (r as any).county, state: (r as any).state,
new_state: !!(r as any).new_state,
unconf_status: !!(r as any).unconf_status,
unconf_pfx: !!(r as any).unconf_pfx,
unconf_cty: !!(r as any).unconf_cty,
unconf_state: !!(r as any).unconf_state,
new_pota: !!(r as any).new_pota,
new_pfx: !!(r as any).new_pfx, pfx: (r as any).pfx,
};
@@ -3852,6 +3874,12 @@ export default function App() {
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
// Only when something is actually in the entry, so an idle digital app doesn't
// wipe a call being typed by hand.
const unsubStatusInval = EventsOn('spotstatus:invalidate', () => {
// The chase rules changed: every resolved verdict is stale. Cleared, and
// the flushes re-ask as rows repaint.
setSpotStatus({});
spotStatusRef.current = {};
});
const unsubClear = EventsOn('udp:clear_call', () => {
// The decoder cleared its DX Call: the next call it announces — even the
// same one re-selected — is a fresh edge.
@@ -3915,7 +3943,7 @@ export default function App() {
const file = String(p?.file ?? '').replace(/^.*[\\/]/, '');
showToast(file ? t('adifmon.toastFrom', { n, file }) : t('adifmon.toast', { n }));
});
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubStatusInval?.(); unsubFlexSpot?.(); unsubTciSpot?.(); unsubProg?.(); unsubBulk?.(); unsubLog?.(); unsubAdifMon?.(); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
@@ -5089,12 +5117,17 @@ export default function App() {
{ type: 'item', label: t('view.refresh'), action: 'view.refresh', shortcut: 'F5' },
{ type: 'item', label: t('view.clearFilters'), action: 'view.clearfilters' },
]},
// The digital-mode corner gets its own menu: the decode list, its map,
// and the grid-square chase all live off the same UDP feed.
{ name: 'ftx', label: t('menu.ftx'), items: [
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
{ type: 'item', label: t('ftmap.tab'), action: 'tools.ftmap' },
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
]},
{ name: 'tools', label: t('menu.tools'), items: [
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
{ type: 'item', label: t('station.title'), action: 'tools.station' },
{ type: 'item', label: t('dec.tab'), action: 'tools.decodes' },
{ type: 'item', label: t('gsm.title'), action: 'tools.grids' },
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
{ type: 'separator' },
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
@@ -5148,6 +5181,7 @@ export default function App() {
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
case 'tools.decodes': openDecodesTab(); break;
case 'tools.ftmap': openFtmapTab(); break;
case 'tools.grids': openGridsTab(); break;
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
@@ -7779,6 +7813,21 @@ export default function App() {
</span>
</TabsTrigger>
)}
{ftmapTabOpen && (
<TabsTrigger value="ftmap" className="gap-1.5">
{t('ftmap.tab')}
<span
role="button"
aria-label="Close FT map"
title="Close"
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
onPointerDown={(e) => { e.stopPropagation(); }}
onClick={(e) => { e.stopPropagation(); closeFtmapTab(); }}
>
<X className="size-3" />
</span>
</TabsTrigger>
)}
{stationTabOpen && (
<TabsTrigger value="station" className="gap-1.5">
{t('station.title')}
@@ -8377,6 +8426,15 @@ export default function App() {
</TabsContent>
)}
{ftmapTabOpen && (
<TabsContent value="ftmap" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
{activeTab === 'ftmap' && (
<div className="h-full w-full min-h-0 bg-card border border-border rounded-lg overflow-hidden">
<FTMapPanel decodes={decodes as any} myGrid={station.my_grid} />
</div>
)}
</TabsContent>
)}
{stationTabOpen && (
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
<StationControlPanel
+8 -3
View File
@@ -65,6 +65,9 @@ export type SpotStatusEntry = {
state?: string;
new_pota?: boolean;
new_pfx?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
pfx?: string;
// lotw: the DX uploads to LoTW, per ARRL user list. Inert until downloaded.
lotw?: boolean;
@@ -304,13 +307,15 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
: s?.status === 'new-slot' ? t('clg2.newSlot')
: s?.status === 'new-call' ? t('clg2.newCall')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
// Dimmed when the need is only a missing confirmation — the grid's
// own convention, now spoken by every category.
parts.push(s?.unconf_status ? { text: label, color: main, dim: true } : { text: label, color: main });
}
// Colours from lib/spotMarkers — shared with the band map so a marker is
// never one colour here and another there.
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county') });
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: markerColour('new_county'), dim: !!s?.unconf_cty });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: markerColour('new_pota') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx') });
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: markerColour('new_pfx'), dim: !!s?.unconf_pfx });
// Worked-but-unconfirmed is a QSL to chase, not a QSO to make. Same hue
// held back, so it reads as "less" of the same thing rather than a
// different fact — and the label says which.
+58 -13
View File
@@ -72,8 +72,14 @@ type StatusEntry = {
new_pota?: boolean;
new_pfx?: boolean;
new_grid?: boolean;
new_state?: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
// "new" = never worked, "unconf" = worked and awaiting a confirmation.
grid_state?: string;
state?: string;
grid?: string;
lotw?: boolean;
};
@@ -112,7 +118,7 @@ interface Props {
//
// All off means no filtering at all: this is a decode LOG first, and a panel
// that starts by hiding most of the band would be lying about what is on it.
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty';
type NewCat = 'dxcc' | 'band' | 'mode' | 'slot' | 'pfx' | 'grid' | 'pota' | 'cty' | 'state';
const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'dxcc', label: 'dec.stNew', colour: 'var(--success)' },
@@ -123,6 +129,7 @@ const NEW_CATS: { key: NewCat; label: string; colour: string }[] = [
{ key: 'grid', label: 'dec.bgGrid', colour: markerColour('new_grid') },
{ key: 'pfx', label: 'dec.bgPfx', colour: markerColour('new_pfx') },
{ key: 'cty', label: 'dec.bgCounty', colour: markerColour('new_county') },
{ key: 'state', label: 'dec.bgState', colour: markerColour('new_state') },
];
// catsOf lists everything a decode is new for. A station can be several at once
@@ -143,6 +150,7 @@ function catsOf(e: StatusEntry | undefined): Set<NewCat> {
if (e.new_grid) out.add('grid');
if (e.new_pfx) out.add('pfx');
if (e.new_county) out.add('cty');
if (e.new_state) out.add('state');
return out;
}
@@ -227,7 +235,7 @@ const CELL_LAST = 'flex items-center min-w-0 px-2 gap-1 overflow-hidden';
// One declaration per column, in display order: the header, the widths and the
// resize handles all read from this, so a column cannot be resized in the header
// and stay the old width in the body.
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'country' | 'status';
type ColKey = 'time' | 'snr' | 'dt' | 'freq' | 'band' | 'mode' | 'msg' | 'grid' | 'state' | 'country' | 'status';
const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
{ key: 'time', tkey: 'dec.colTime', def: 64, min: 44 },
{ key: 'snr', tkey: 'dec.colSnr', def: 50, min: 36 },
@@ -241,9 +249,26 @@ const COLS: { key: ColKey; tkey: string; def: number; min: number }[] = [
// The grid was carried and shown nowhere: it drives the NEW GRID badge, and
// an operator chasing squares could see the verdict but never the square.
{ key: 'grid', tkey: 'dec.colGrid', def: 62, min: 46 },
// For the WAS chasers: the badge carries the two letters, the name spells
// them out — "SD" alone is a quiz for a European.
{ key: 'state', tkey: 'dec.colState', def: 120, min: 56 },
{ key: 'country', tkey: 'dec.colCountry', def: 140, min: 70 },
{ key: 'status', tkey: 'dec.colStatus', def: 186, min: 80 },
];
const US_STATES: Record<string, string> = {
AL: 'Alabama', AK: 'Alaska', AZ: 'Arizona', AR: 'Arkansas', CA: 'California',
CO: 'Colorado', CT: 'Connecticut', DE: 'Delaware', FL: 'Florida', GA: 'Georgia',
HI: 'Hawaii', ID: 'Idaho', IL: 'Illinois', IN: 'Indiana', IA: 'Iowa',
KS: 'Kansas', KY: 'Kentucky', LA: 'Louisiana', ME: 'Maine', MD: 'Maryland',
MA: 'Massachusetts', MI: 'Michigan', MN: 'Minnesota', MS: 'Mississippi',
MO: 'Missouri', MT: 'Montana', NE: 'Nebraska', NV: 'Nevada', NH: 'New Hampshire',
NJ: 'New Jersey', NM: 'New Mexico', NY: 'New York', NC: 'North Carolina',
ND: 'North Dakota', OH: 'Ohio', OK: 'Oklahoma', OR: 'Oregon', PA: 'Pennsylvania',
RI: 'Rhode Island', SC: 'South Carolina', SD: 'South Dakota', TN: 'Tennessee',
TX: 'Texas', UT: 'Utah', VT: 'Vermont', VA: 'Virginia', WA: 'Washington',
WV: 'West Virginia', WI: 'Wisconsin', WY: 'Wyoming', DC: 'District of Columbia',
};
const COL_MAX = 600;
const COLW_KEY = 'opslog.decodeColWidths';
@@ -308,12 +333,15 @@ function ColResizer({ onResize, onReset }: { onResize: (dx: number) => void; onR
//
// Colours match the cluster list and the band map — the same fact must not be
// amber in one panel and green in the next.
const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground' },
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground' },
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground' },
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground' },
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
const ENTITY_BADGE: Record<string, { label: string; cls: string; colour: string }> = {
// colour is the category's own hue, for the UNCONFIRMED rendering: the
// filled cls puts light text on a filled chip, and dimming that to a
// transparent background left light-on-nothing — an invisible badge.
'new': { label: 'dec.stNew', cls: 'bg-success text-success-foreground', colour: 'var(--success)' },
'new-band': { label: 'dec.stBand', cls: 'bg-warning text-warning-foreground', colour: 'var(--warning)' },
'new-mode': { label: 'dec.stMode', cls: 'bg-info text-info-foreground', colour: 'var(--info)' },
'new-slot': { label: 'dec.stSlot', cls: 'bg-caution text-caution-foreground', colour: 'var(--caution)' },
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground', colour: 'var(--muted-foreground)' },
};
// entityBadgesFor turns a status into the badges that describe it.
@@ -324,7 +352,7 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
// passed the BAND and MODE filters and then showed no reason for being there,
// which is precisely what was reported. catsOf has always split the status into
// its two categories; this is the same split, on the screen.
function entityBadgesFor(status: string): { label: string; cls: string }[] {
function entityBadgesFor(status: string): { label: string; cls: string; colour: string }[] {
if (status === 'new-band-mode') {
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
}
@@ -343,6 +371,7 @@ function entityBadgesFor(status: string): { label: string; cls: string }[] {
const EXTRA_BADGES: { key: keyof StatusEntry; marker: SpotMarkerKey; label: string }[] = [
{ key: 'new_pota', marker: 'new_pota', label: 'dec.bgPota' },
{ key: 'new_grid', marker: 'new_grid', label: 'dec.bgGrid' },
{ key: 'new_state', marker: 'new_state', label: 'dec.bgState' },
{ key: 'new_pfx', marker: 'new_pfx', label: 'dec.bgPfx' },
{ key: 'new_county', marker: 'new_county', label: 'dec.bgCounty' },
];
@@ -1097,6 +1126,15 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
<span className="truncate">{d.grid || e?.grid || ''}</span>
</span>
<span className={cn(CELL, 'gap-1.5')}>
{e?.state && (
<>
<span className="rounded px-1.5 py-px text-[11px] font-bold bg-info/15 text-info border border-info/40 shrink-0">{e.state}</span>
<span className="text-[11px] text-muted-foreground truncate">{US_STATES[e.state.toUpperCase()] ?? ''}</span>
</>
)}
</span>
<span className={cn(CELL, 'text-[11px] text-muted-foreground')}>
<span className="truncate">{e?.country ?? ''}</span>
</span>
@@ -1114,7 +1152,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
</span>
)}
{entities.map((b) => (
<span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
<span key={b.label}
title={e?.unconf_status ? t('dec.unconfTip') : undefined}
className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0',
e?.unconf_status ? 'border bg-transparent' : b.cls)}
style={e?.unconf_status ? { color: b.colour, borderColor: b.colour, borderStyle: 'dashed', opacity: 0.6 } : undefined}>
{t(b.label)}
</span>
))}
@@ -1124,16 +1166,19 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
// Same hue, held back — the badge reads as "less" of the
// same thing, and its label says which. One badge for both
// is what made a station worked an hour ago read as new.
const unconf = b.key === 'new_grid' && e?.grid_state === 'unconf';
const unconf = (b.key === 'new_grid' && e?.grid_state === 'unconf')
|| (b.key === 'new_state' && !!e?.unconf_state)
|| (b.key === 'new_county' && !!e?.unconf_cty)
|| (b.key === 'new_pfx' && !!e?.unconf_pfx);
const c = markerColour(b.marker);
return (
<span key={b.key as string}
title={unconf ? t('dec.bgGridUnconfTip') : undefined}
title={unconf ? t('dec.unconfTip') : undefined}
className="rounded border px-1 py-px text-[10px] font-semibold uppercase tracking-wide bg-transparent shrink-0"
style={unconf
? { borderColor: c, color: c, opacity: 0.45, borderStyle: 'dashed' }
: { borderColor: c, color: c }}>
{unconf ? t('dec.bgGridUnconf') : t(b.label)}
{t(b.label)}
</span>
);
})}
+154
View File
@@ -0,0 +1,154 @@
import { useEffect, useRef, useState } from 'react';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import { gridToLatLon, greatCirclePoints } from '@/lib/maidenhead';
import { BASEMAPS, type BasemapKey } from '@/components/MainMap';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
// FT Map — the live decode feed as geography: every station decoded in the
// last half hour, an arc from the operator's own square to theirs, coloured by
// band the way PSK Reporter taught everyone to read it. Wholly display: the
// decode list is the same one the FT decodes tab shows, and a station with no
// grid (never sent one in a CQ) simply cannot be placed and is not drawn.
//
// Performance is a design constraint, not an afterthought: the panel only
// exists while its tab is active (the parent unmounts it otherwise), the map
// renders with canvas (one <canvas>, not one DOM node per arc), the arcs are
// capped, and redraws happen when the DECODE LIST changes — every 15 s in FT8,
// not per frame.
export type FTMapDecode = {
call: string;
grid?: string;
band?: string;
snr: number;
at: string;
};
// The band palette every PSK Reporter user already knows, near enough.
const BAND_COLOURS: Record<string, string> = {
'160m': '#7f7f7f', '80m': '#e550e5', '60m': '#00008b', '40m': '#5555ff',
'30m': '#62d962', '20m': '#f2c40c', '17m': '#f2f261', '15m': '#cca166',
'12m': '#b22222', '10m': '#ff69b4', '6m': '#ff0000', '4m': '#cc0044',
'2m': '#ff1493', '70cm': '#999900',
};
const bandColour = (b?: string) => BAND_COLOURS[(b ?? '').toLowerCase()] || '#9ca3af';
const MAX_ARCS = 300;
const MAX_AGE_MS = 30 * 60_000;
export function FTMapPanel({ decodes, myGrid }: { decodes: FTMapDecode[]; myGrid: string }) {
const { t } = useI18n();
const divRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<L.Map | null>(null);
const layerRef = useRef<L.LayerGroup | null>(null);
const baseRef = useRef<L.TileLayer | null>(null);
const labelsRef = useRef<L.TileLayer | null>(null);
const [basemap, setBasemap] = useState<BasemapKey>(() =>
(localStorage.getItem('opslog.ftmapBase') as BasemapKey) || 'satellite');
// The map itself, once.
useEffect(() => {
if (!divRef.current || mapRef.current) return;
// ONE world: noWrap tiles inside hard bounds, no side-by-side copies —
// and the space beyond the edge is the theme's own surface (style.css).
const m = L.map(divRef.current, {
zoomControl: true, attributionControl: true,
// No maxBounds: with the world smaller than the window the clamp
// dragged every zoom into a corner. noWrap tiles alone keep one world.
worldCopyJump: false, preferCanvas: true,
center: [25, 0], zoom: 2, minZoom: 2,
});
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
return () => { m.remove(); mapRef.current = null; layerRef.current = null; };
}, []);
// Basemap follows the picker.
useEffect(() => {
const m = mapRef.current;
if (!m) return;
baseRef.current?.remove(); labelsRef.current?.remove();
const bm = BASEMAPS[basemap];
const opts: L.TileLayerOptions = {
maxNativeZoom: bm.maxNativeZoom,
noWrap: true,
bounds: L.latLngBounds(L.latLng(-85.0511, -180), L.latLng(85.0511, 180)),
};
baseRef.current = L.tileLayer(bm.url, { ...opts, attribution: bm.attr, subdomains: bm.subdomains ?? 'abc' }).addTo(m);
if (bm.labelsUrl) labelsRef.current = L.tileLayer(bm.labelsUrl, opts).addTo(m);
localStorage.setItem('opslog.ftmapBase', basemap);
}, [basemap]);
// The arcs, redrawn when the decode list changes. Newest last so they paint
// on top; opacity falls with age so the map reads as "now" with a memory.
useEffect(() => {
const layer = layerRef.current;
if (!layer) return;
layer.clearLayers();
const from = gridToLatLon(myGrid);
if (!from) return;
const now = Date.now();
const placed = decodes
.filter((d) => d.grid && Date.parse(d.at) > now - MAX_AGE_MS)
.slice(-MAX_ARCS);
// One line per CALL (its freshest sighting): the same CQer decoded thirty
// times in ten minutes is one path on the air, not thirty strokes of it.
const byCall = new Map<string, FTMapDecode>();
for (const d of placed) byCall.set(d.call.toUpperCase(), d);
L.circleMarker([from.lat, from.lon], {
radius: 5, color: '#fff', weight: 2, fillColor: '#e11d48', fillOpacity: 1,
}).addTo(layer);
for (const d of byCall.values()) {
const to = gridToLatLon(d.grid!);
if (!to) continue;
const age = now - Date.parse(d.at);
const fade = Math.max(0.15, 1 - age / MAX_AGE_MS);
const colour = bandColour(d.band);
const pts = greatCirclePoints(from.lat, from.lon, to.lat, to.lon, 48);
L.polyline(pts as L.LatLngExpression[], {
color: colour, weight: 1.3, opacity: 0.65 * fade, smoothFactor: 0,
}).addTo(layer);
L.circleMarker([to.lat, to.lon], {
radius: 3, color: colour, weight: 1, fillColor: colour, fillOpacity: 0.9 * fade,
}).bindTooltip(`${d.call} · ${d.grid} · ${d.snr > 0 ? '+' : ''}${d.snr} dB`, { direction: 'top' })
.addTo(layer);
}
}, [decodes, myGrid]);
const bands = [...new Set(decodes.map((d) => (d.band ?? '').toLowerCase()).filter(Boolean))];
return (
<div className="relative h-full w-full min-h-0">
<div ref={divRef} className="absolute inset-0 rounded-lg overflow-hidden" />
{/* Basemap picker, MainMap's own vocabulary. */}
<div className="absolute top-2 left-12 z-[1000] flex gap-1 rounded-md bg-background/85 backdrop-blur px-1 py-1 border border-border">
{(Object.keys(BASEMAPS) as BasemapKey[]).map((k) => (
<button key={k} type="button" onClick={() => setBasemap(k)}
className={cn('px-2 py-0.5 rounded text-[11px]',
basemap === k ? 'bg-primary text-primary-foreground font-semibold' : 'text-muted-foreground hover:bg-muted')}>
{BASEMAPS[k].label}
</button>
))}
</div>
{/* Band legend — only the bands actually on screen. */}
{bands.length > 0 && (
<div className="absolute bottom-2 left-2 z-[1000] flex flex-wrap gap-x-2.5 gap-y-1 rounded-md bg-background/85 backdrop-blur px-2 py-1.5 border border-border">
{bands.map((b) => (
<span key={b} className="flex items-center gap-1 text-[11px] text-foreground">
<span className="inline-block w-3 h-[3px] rounded" style={{ background: bandColour(b) }} />
{b.toUpperCase()}
</span>
))}
</div>
)}
{!gridToLatLon(myGrid) && (
<div className="absolute inset-0 z-[1000] flex items-center justify-center pointer-events-none">
<span className="rounded-md bg-background/90 border border-border px-3 py-2 text-sm text-muted-foreground">
{t('ftmap.noGrid')}
</span>
</div>
)}
</div>
);
}
+1 -5
View File
@@ -128,15 +128,11 @@ export function GridSquareMap({ myGrid, className }: { myGrid?: string; classNam
// impossible on any window wider than it is tall: one step out was already
// too far in.
minZoom: 0,
maxBoundsViscosity: 1, // don't let a drag slide the world off to one side
}).setView([20, 0], 2);
// The whole world, once, whatever the window is shaped like — computed by
// Leaflet from the container rather than guessed with a zoom number.
m.fitWorld({ animate: false });
// Latitude only: the poles are the edge of the projection and there is
// nothing beyond them, while leaving longitude free keeps a drag from
// fighting the operator near the date line.
m.setMaxBounds(L.latLngBounds(L.latLng(-85, -Infinity), L.latLng(85, Infinity)));
mapRef.current = m;
layerRef.current = L.layerGroup().addTo(m);
// Leaflet measures its container ONCE, when the map is created, and never
+37 -10
View File
@@ -10,6 +10,7 @@ import {
GetLookupSettings, SaveLookupSettings, ClearLookupCache, TestLookupProvider,
GetListsSettings, SaveListsSettings,
GetCATSettings, SaveCATSettings, GetRadios, SaveRadios, SetActiveRadio, ActiveRadioID, DiscoverFlexRadios, DVKDelete,
GetChaseSettings, SaveChaseSettings,
GetAudioMonitorPref,
ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile,
GetRotators, SaveRotators, TestRotatorDevice, RotatorPark, RotatorStop,
@@ -1718,6 +1719,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
// DVK voice-keyer messages (F1F6).
type DVKMsg = { slot: number; label: string; has_audio: boolean; duration_sec: number };
type DVKStat = { recording: boolean; playing: boolean; rec_slot: number };
const [chaseCfg, setChaseCfg] = useState<{ mode: string; sources: string[] }>({ mode: 'new', sources: ['lotw', 'card', 'eqsl'] });
useEffect(() => { GetChaseSettings().then((c: any) => setChaseCfg({ mode: c?.mode ?? 'new', sources: c?.sources ?? ['lotw', 'card', 'eqsl'] })).catch(() => {}); }, []);
const [dvkMsgs, setDvkMsgs] = useState<DVKMsg[]>([]);
const [dvkStat, setDvkStat] = useState<DVKStat>({ recording: false, playing: false, rec_slot: 0 });
const [dvkErr, setDvkErr] = useState('');
@@ -5420,6 +5423,38 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
{/* Chase switches: off, the marker stops being TOLD, everywhere at
once badge, colour, filter chip, reference column. A "new band
+ new POTA" spot then reads NEW BAND alone. */}
{/* The GLOBAL hunt: every category (DXCC, band, mode, slot, prefix,
county, state, grid) judged as new-only or new-plus-unconfirmed,
against the confirmation sources the operator trusts. */}
<div className="rounded-lg border border-border p-3 space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('chg.mode')}</span>
<Select value={chaseCfg.mode} onValueChange={(v) => { const next = { ...chaseCfg, mode: v }; setChaseCfg(next); void SaveChaseSettings(next as any); }}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
{chaseCfg.mode === 'new_unconfirmed' && (
<div className="flex items-center gap-4 flex-wrap pl-2">
<span className="text-xs text-muted-foreground">{t('chg.sources')}</span>
{([['lotw', 'LoTW'], ['card', t('chg.card')], ['eqsl', 'eQSL'], ['qrz', 'QRZ.com']] as const).map(([k, label]) => (
<label key={k} className="flex items-center gap-1.5 text-xs cursor-pointer">
<Checkbox checked={chaseCfg.sources.includes(k)}
onCheckedChange={(c) => {
const sources = c ? [...chaseCfg.sources, k] : chaseCfg.sources.filter((x) => x !== k);
const next = { ...chaseCfg, sources };
setChaseCfg(next); void SaveChaseSettings(next as any);
}} />
{label}
</label>
))}
</div>
)}
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chasePotaHint')}>
<Checkbox checked={chasePotaOn}
onCheckedChange={(c) => { const v = !!c; setChasePotaOn(v); writeUiPref('opslog.chasePota', v ? '1' : '0'); }} />
@@ -5459,19 +5494,11 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-muted-foreground w-28 shrink-0">{t('gsc.hunt')}</span>
<Select value={gridScope.hunt} onValueChange={(v) => saveGridScope({ ...gridScope, hunt: v })}>
<SelectTrigger className="h-7 w-64 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="new">{t('gsc.huntNew')}</SelectItem>
<SelectItem value="new_unconfirmed">{t('gsc.huntUnconf')}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
{/* Chase new its own option, NOT nested under grid chasing. Chasing
squares and chasing entities are different wants; they only share
the PSK Reporter feed, which either one brings up. */}
+6 -5
View File
@@ -391,11 +391,12 @@ export function WatchlistTab({ spots, spotStatus, onSpotSelect, onSpotClick }: P
className={cn('w-full flex items-center gap-2 px-2 py-1.5 rounded text-[11px] bg-muted/25 hover:bg-muted/70 transition-colors text-left',
!done && 'border-l-[3px] border-warning')}>
{done && <span className='font-bold shrink-0 text-success'></span>}
<span className="font-mono font-bold text-info shrink-0">{s.dx_call}</span>
<span className="text-muted-foreground truncate flex-1 min-w-0 max-w-56">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0">{s.band}</span>
{mode && <span className="px-1.5 rounded shrink-0" style={{ color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' }}>{mode}</span>}
<span className="font-mono text-muted-foreground shrink-0">{(s.freq_hz / 1e6).toFixed(4)}</span>
{/* Fixed columns: an elastic country made band/mode/freq start wherever the name ended — every row its own ruler. */}
<span className="font-mono font-bold text-info shrink-0 w-24 truncate">{s.dx_call}</span>
<span className="text-muted-foreground truncate shrink-0 w-44">{(s as any).country ?? ''}</span>
<span className="px-1.5 rounded bg-muted shrink-0 w-11 text-center">{s.band}</span>
<span className="px-1.5 rounded shrink-0 w-11 text-center" style={mode ? { color: 'var(--chart-5)', background: 'color-mix(in srgb, var(--chart-5) 12%, transparent)' } : undefined}>{mode || ' '}</span>
<span className="font-mono text-muted-foreground shrink-0 w-16 text-right">{(s.freq_hz / 1e6).toFixed(4)}</span>
{badge && chip(badge.color, badge.label)}
<div className="flex-1" />
{done
+12 -12
View File
@@ -25,7 +25,7 @@ const en: Dict = {
'upd.retry': 'Retry', 'upd.browser': 'Open page', 'upd.checking': 'Checking for updates…', 'upd.upToDate': "You're up to date",
'rate.title': 'QSO rate (QSOs/hour) — projected from the last 10 / 60 minutes',
'lotw.feedStale': "ARRL's user list itself was last built on {date}, so this figure may be older than the station", 'lotw.userTip': 'LoTW user — last upload {date} ({days} days ago)',
'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'menu.ftx': 'FTx', 'menu.file': 'File', 'menu.edit': 'Edit', 'menu.view': 'View', 'menu.tools': 'Tools',
'file.import': 'Import ADIF…', 'file.export': 'Export ADIF…', 'file.exporting': 'Exporting…',
'file.exportCabrillo': 'Export Cabrillo…', 'file.deleteAll': 'Delete all QSOs…', 'file.exit': 'Exit',
'edit.editSel': 'Edit selected QSO…', 'edit.prefs': 'Preferences…',
@@ -162,7 +162,7 @@ const en: Dict = {
'mx.tipThisCall': 'already worked with this callsign',
'mx.tipThisCallConf': 'already confirmed with this callsign',
// FTx decodes panel (Tools -> FT decodes)
'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only',
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Set your station grid in Preferences to place the map.', 'dec.tab': 'FT decodes', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ only',
'dec.allBands': 'All bands', 'dec.allModes': 'All modes', 'toast.qsoLogged': 'QSO logged', 'dec.contsHint': 'Continents: click to keep one or several', 'dec.allConts': 'All continents',
'dec.minSnrTitle': 'Hide anything weaker than this SNR', 'dec.searchPh': 'Call, grid or message',
'dec.clearFilters': 'Clear', 'dec.live': 'live', 'dec.tx': 'TX',
@@ -176,8 +176,8 @@ const en: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Audio offset inside the passband (Hz)',
'dec.txNow': 'Transmitting', 'dec.txIdle': 'Transmit', 'dec.working': 'calling', 'dec.toYou': 'to you',
'dec.txUnknown': 'transmitting — text not reported', 'dec.txNothing': 'nothing being sent',
'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.wkd': 'Wkd',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
'dec.colTime': 'Time', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'This square is worked but not yet confirmed — a QSL to chase, not a QSO to make.', 'dec.colGrid': 'Grid', 'dec.colState': 'State', 'dec.colCountry': 'Country', 'dec.colBand': 'Band', 'dec.colMode': 'Mode', 'dec.colStatus': 'Status', 'dec.stateTip': 'US state', 'dec.wkd': 'Wkd',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'GRID', 'dec.bgPfx': 'PFX', 'dec.bgState': 'New State', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NEW', 'dec.stBand': 'BAND', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'CALL',
'dec.empty': 'Nothing decoded yet. Decodes arrive from WSJT-X, JTDX or MSHV over the inbound UDP link (Settings -> UDP).',
'dec.emptyFiltered': 'No decode matches these filters.',
@@ -344,7 +344,7 @@ const en: Dict = {
'clu.slotHighlight': 'Colour the stations not worked on this band and mode',
'clu.slotHighlightHint': '(by callsign, whatever the entity status says)',
'rq.searchPh': 'Search callsign… 4S · *4S · *4S*', 'rq.searchTip': 'A plain word matches the START of a callsign: 4S finds 4S7AB. * is any run of characters and ? is exactly one, so *4S ends with 4S, *4S* contains it anywhere, and F?BPO matches F4BPO.',
'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
'chg.mode': 'Chase', 'chg.sources': 'Confirmed by', 'chg.card': 'QSL card', 'dec.unconfTip': 'Worked but not confirmed — a QSL to chase', 'gsc.scope': 'Match a square by', 'gsc.hunt': 'Chase', 'gsc.huntNew': 'New — never worked', 'gsc.huntUnconf': 'New and unconfirmed', 'gsc.scope_band_digi': 'This band + any digital mode', 'gsc.scope_band_mode': 'This band + this exact mode', 'gsc.scope_band_ftx': 'This band + any FT mode (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Any band + any digital mode', 'gsc.scope_mix_mode': 'Any band + this exact mode', 'gsc.scope_mix_ftx': 'Any band + any FT mode (FT8/FT4/FT2)', 'gsc.hint': 'Decides when a square stops being NEW. Narrower means more squares to chase: per band and per exact mode is the most demanding, any band and any digital mode the least. Chasing unconfirmed as well keeps a square wanted until a QSL, LoTW or eQSL confirmation arrives — it is still missing from the award until then.',
'gsm.basemap': 'Basemap', 'gsm.title': 'Grid squares', 'gsm.all': 'All', 'gsm.phone': 'Phone', 'gsm.cw': 'CW', 'gsm.digital': 'Digital', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmed', 'gsm.worked': 'worked', 'gsm.colConfirmed': 'Colour for confirmed squares', 'gsm.colWorked': 'Colour for worked (unconfirmed) squares', 'gsm.colReset': 'Back to the theme colours', 'gsm.refresh': 'Recount from the log', 'gsm.count': '{n} squares · {c} confirmed',
'bo.nearKm': 'Count receivers within', 'bo.nearKmHint': 'A report proves YOUR path only if it was collected near you. Smaller is more local but leaves fewer receivers listening — too small and the watch has nothing to look at. 300 km borrows a whole region; 100 km suits 2 m, where a duct is narrow.',
'bo.open': 'open', 'bo.liveTip': '{band} is open — {n} stations, ~{km} km, {sector}{season}. Click for the band map.', 'bo.enable': 'Watch for band openings', 'bo.enableHint': '(10, 12, 6, 4 and 2 m. Switching this on adds the two RBN nodes and subscribes to the PSK Reporter feed — the detection needs far more ears than a cluster can give it.)', 'bo.feedUp': 'PSK Reporter feed up — {n} decodes seen', 'bo.feedDown': 'PSK Reporter feed down — needs your station grid, and a moment to connect', 'clu.spotTtl': 'Spot lifetime', 'clu.spotTtlNever': 'Keep', 'clu.spotMax': 'Spots kept', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 keeps them.', 'clu.chasePota': 'Chase POTA', 'clu.chasePotaHint': 'Off: no NEW POTA badge or filter, and the POTA column stays empty — a new-band + new-POTA spot reads NEW BAND alone.', 'clu.chaseSota': 'Chase SOTA', 'clu.chaseSotaHint': 'Off: the SOTA column stays empty.', 'clu.chaseGrids': 'Chase new grids', 'clu.chaseGridsHint': '(learns locators from your own WSJT-X decodes AND from PSK Reporter, and keeps them in their own database so the cluster shows them from the first second)', 'clu.chaseGridsStat': '{n} locators known — {p} waiting to be written', 'clu.workedSameSlot': 'Already worked only on the same slot',
@@ -521,7 +521,7 @@ const en: Dict = {
'wbg.awardTip': '{name} — reference this QSO counts for', 'wbg.typeCall': 'Type a callsign in the entry strip to see prior contacts.', 'wbg.checking': 'checking…', 'wbg.new': 'NEW', 'wbg.noPriorPre': 'No prior QSO with ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Worked before', 'wbg.first': 'First:', 'wbg.last': 'Last:', 'wbg.dxcc': 'DXCC:', 'wbg.entityQsos': '{n} entity QSOs', 'wbg.clearFiltersTitle': 'Clear all column filters', 'wbg.clearFilters': 'Clear filters', 'wbg.columns': 'Columns', 'wbg.olderQsos': '+ {n} older QSOs (not shown — capped for performance)', 'wbg.pickerTitle': 'Worked-before columns', 'wbg.pickerDesc': 'Pick the columns you want visible in the Worked-before table.', 'wbg.allGroups': 'All groups:', 'wbg.all': 'all', 'wbg.none': 'none', 'wbg.grpAwards': 'Awards', 'wbg.resetDefaults': 'Reset to defaults', 'wbg.done': 'Done',
'chn.title': 'Chase new', 'chn.close': 'Hide the panel', 'chn.filterHint': 'Show or hide this kind', 'chn.allFiltered': 'Everything heard is filtered out — turn a category back on above.', 'chn.toggle': 'Chase new', 'chn.count': '{n} heard', 'chn.loading': 'Waiting for the feed…', 'chn.empty': 'Nothing new being decoded near you right now.', 'chn.digitalOnly': 'PSK Reporter — digital modes only, heard within ~300 km of you.', 'chn.option': 'Chase new (PSK Reporter)', 'chn.optionHelp': 'Lists stations being decoded near you that are new against your log — new entity, band, mode, slot, prefix or square. Digital modes only.', 'chn.show': 'Chase new panel',
'clg2.allFiltered': '{n} spots received, none shown — your filters are hiding them all.', 'clg2.activeFilters': 'Active:', 'clg2.clearAllFilters': 'Clear every filter', 'clg2.fBandLock': 'band locked to the rig', 'clg2.fBands': 'bands {list}', 'clg2.fModeLock': 'mode locked to the rig', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'status chips', 'clg2.fHideWorked': 'hide worked', 'clg2.fLotwOnly': 'LoTW users only', 'clg2.fSpotterCont': 'spotter continent', 'clg2.fSource': 'one source node', 'clg2.fSearch': 'search “{q}”',
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
'clg2.c.time': 'Time', 'clg2.c.call': 'Call', 'clg2.c.status': 'Status', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Freq', 'clg2.c.band': 'Band', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Pfx', 'clg2.c.cqz': 'CQ Zone', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'ITU Zone', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Short path (°)', 'clg2.h.sp_deg': 'SP°', 'clg2.c.lp_deg': 'Long path (°)', 'clg2.h.lp_deg': 'LP°', 'clg2.c.country': 'Country', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Spotter locator', 'clg2.h.locator': 'Spotter loc', 'clg2.c.county': 'US County', 'clg2.tipNewCounty': 'NEW COUNTY — never worked', 'clg2.tipNewPfx': 'NEW PREFIX — this WPX prefix has never been worked', 'clg2.c.comment': 'Comment', 'clg2.c.received_at': 'Received at', 'clg2.h.received_at': 'Received UTC', 'clg2.c.raw': 'Raw', 'clg2.newDxcc': 'NEW DXCC', 'clg2.newBandMode': 'NEW B+M', 'clg2.newBand': 'NEW BAND', 'clg2.newMode': 'NEW MODE', 'clg2.newSlot': 'NEW SLOT', 'clg2.newCall': 'NEW CALL', 'clg2.wkdCall': 'WKD CALL', 'clg2.newCounty': 'NEW CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newState': 'New State', 'clg2.newGrid': 'NEW GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NEW GRID — this square has never been worked (grid heard in a CQ on the UDP link)', 'clg2.newPfx': "NEW PFX", 'clg2.newPota': 'NEW POTA', 'clg2.tipNewDxcc': 'NEW DXCC: {country}', 'clg2.tipWorkedCall': 'Already worked this call', 'clg2.tipNewBandMode': 'NEW BAND AND NEW MODE for this entity — neither has been worked with it', 'clg2.tipNewBand': 'NEW BAND for this entity', 'clg2.tipNewSlotBand': 'NEW SLOT (mode not yet worked on this band)', 'clg2.tipNewMode': 'NEW MODE (this mode never worked on this entity)', 'clg2.tipNewSlot': 'NEW SLOT (this band+mode not yet worked)', 'clg2.tipNewCall': 'NEW CALL — this callsign has never been worked on this band and mode (the entity has)', 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Geo', 'clg2.clearFiltersTitle': 'Clear all column filters', 'clg2.clearFilters': 'Clear filters', 'clg2.newSpots': '{n} new spots — click to resume', 'clg2.columns': 'Columns', 'clg2.pickerTitle': 'Cluster columns', 'clg2.pickerDesc': 'Pick the columns you want visible in the Cluster table.', 'clg2.allGroups': 'All groups:', 'clg2.all': 'all', 'clg2.none': 'none', 'clg2.resetDefaults': 'Reset to defaults', 'clg2.done': 'Done',
// Audio devices & voice keyer (Preferences → Audio devices).
'aud.refreshDevices': 'Refresh devices', 'aud.fromRadio': 'From Radio (RX in)', 'aud.toRadio': 'To Radio (TX out)', 'aud.recMic': 'Recording mic', 'aud.listening': 'Listening (preview)',
'aud.phFromRadio': 'Rig audio output → soundcard input', 'aud.phToRadio': 'Soundcard output → rig mic/data in', 'aud.phRecMic': 'Your microphone (record voice-keyer messages)', 'aud.phListening': 'Local speakers for preview',
@@ -549,7 +549,7 @@ const fr: Dict = {
'live.stationsTitle': 'Stations on air', 'live.stationsEmpty': 'Aucune station ne reporte pour le moment.', 'live.stationsHide': 'Masquer',
'rate.title': 'Rythme QSO (QSO/heure) — projeté sur les 10 / 60 dernières minutes',
'lotw.feedStale': "la liste ARRL elle-même date du {date}, ce chiffre peut donc être plus vieux que la station", 'lotw.userTip': 'Utilisateur LoTW — dernier upload {date} (il y a {days} j)',
'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'menu.ftx': 'FTx', 'menu.file': 'Fichier', 'menu.edit': 'Édition', 'menu.view': 'Affichage', 'menu.tools': 'Outils',
'file.import': 'Importer ADIF…', 'file.export': 'Exporter ADIF…', 'file.exporting': 'Export…',
'file.exportCabrillo': 'Exporter Cabrillo…', 'file.deleteAll': 'Supprimer tous les QSO…', 'file.exit': 'Quitter',
'edit.editSel': 'Éditer le QSO sélectionné…', 'edit.prefs': 'Préférences…',
@@ -682,7 +682,7 @@ const fr: Dict = {
'mx.tipThisCall': 'déjà contacté avec cet indicatif',
'mx.tipThisCallConf': 'déjà confirmé avec cet indicatif',
// Panneau des decodes FTx (Outils -> Decodes FT)
'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement',
'ftmap.tab': 'FT Map', 'ftmap.noGrid': 'Renseigne ton locator dans les Préférences pour placer la carte.', 'dec.tab': 'Decodes FT', 'dec.title': 'Decodes', 'dec.cqOnly': 'CQ seulement',
'dec.allBands': 'Toutes bandes', 'dec.allModes': 'Tous modes', 'toast.qsoLogged': 'QSO enregistré', 'dec.contsHint': 'Continents : clique pour en garder un ou plusieurs', 'dec.allConts': 'Tous continents',
'dec.minSnrTitle': 'Masquer tout ce qui est plus faible que ce rapport', 'dec.searchPh': 'Indicatif, locator ou message',
'dec.clearFilters': 'Effacer', 'dec.live': 'en direct', 'dec.tx': 'TX',
@@ -696,8 +696,8 @@ const fr: Dict = {
'dec.colFreq': 'Freq', 'dec.colFreqTitle': 'Decalage audio dans la bande passante (Hz)',
'dec.txNow': 'En emission', 'dec.txIdle': 'Emission', 'dec.working': 'appelle', 'dec.toYou': 'pour toi',
'dec.txUnknown': 'en émission — texte non communiqué', 'dec.txNothing': 'rien en cours d’émission',
'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.wkd': 'Fait',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgCounty': 'CTY',
'dec.colTime': 'Heure', 'dec.colSnr': 'SNR', 'dec.colMsg': 'Message', 'dec.bgGridUnconf': 'GRID?', 'dec.bgGridUnconfTip': 'Ce carré est contacté mais pas encore confirmé — une QSL à relancer, pas un QSO à faire.', 'dec.colGrid': 'Locator', 'dec.colState': 'État', 'dec.colCountry': 'Pays', 'dec.colBand': 'Bande', 'dec.colMode': 'Mode', 'dec.colStatus': 'Statut', 'dec.stateTip': 'État US', 'dec.wkd': 'Fait',
'dec.bgPota': 'POTA', 'dec.bgGrid': 'LOC', 'dec.bgPfx': 'PFX', 'dec.bgState': 'Nouvel État', 'dec.bgCounty': 'CTY',
'dec.stNew': 'NOUV', 'dec.stBand': 'BANDE', 'dec.stMode': 'MODE', 'dec.stSlot': 'SLOT', 'dec.stCall': 'IND',
'dec.empty': "Aucun decode pour l'instant. Ils arrivent de WSJT-X, JTDX ou MSHV par le lien UDP entrant (Reglages -> UDP).",
'dec.emptyFiltered': 'Aucun decode ne correspond a ces filtres.',
@@ -858,7 +858,7 @@ const fr: Dict = {
'clu.slotHighlight': 'Colorer les stations non contactées sur cette bande et ce mode',
'clu.slotHighlightHint': "(par indicatif, quel que soit le statut de l'entité)",
'rq.searchPh': 'Chercher un indicatif… 4S · *4S · *4S*', 'rq.searchTip': 'Un mot simple correspond au DÉBUT de lindicatif : 4S trouve 4S7AB. * remplace nimporte quelle suite de caractères et ? exactement un, donc *4S se termine par 4S, *4S* le contient nimporte où, et F?BPO correspond à F4BPO.',
'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus cest étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme dici là.',
'chg.mode': 'Chasse', 'chg.sources': 'Confirmé par', 'chg.card': 'Carte QSL', 'dec.unconfTip': 'Contacté mais non confirmé — une QSL à chasser', 'gsc.scope': 'Carré déjà fait selon', 'gsc.hunt': 'Chasser', 'gsc.huntNew': 'Nouveau — jamais contacté', 'gsc.huntUnconf': 'Nouveau et non confirmé', 'gsc.scope_band_digi': 'Cette bande + tout mode numérique', 'gsc.scope_band_mode': 'Cette bande + ce mode exact', 'gsc.scope_band_ftx': 'Cette bande + tout mode FT (FT8/FT4/FT2)', 'gsc.scope_mix_digi': 'Toutes bandes + tout mode numérique', 'gsc.scope_mix_mode': 'Toutes bandes + ce mode exact', 'gsc.scope_mix_ftx': 'Toutes bandes + tout mode FT (FT8/FT4/FT2)', 'gsc.hint': 'Détermine quand un carré cesse d’être NEW. Plus cest étroit, plus il y a de carrés à chasser : par bande et par mode exact est le plus exigeant, toutes bandes et tout numérique le moins. Chasser aussi les non confirmés garde un carré recherché jusqu’à une confirmation QSL, LoTW ou eQSL — il manque toujours au diplôme dici là.',
'gsm.basemap': 'Fond de carte', 'gsm.title': 'Carrés locator', 'gsm.all': 'Tout', 'gsm.phone': 'Phonie', 'gsm.cw': 'CW', 'gsm.digital': 'Numérique', 'gsm.ftx': 'FTx', 'gsm.confirmed': 'confirmés', 'gsm.worked': 'contactés', 'gsm.colConfirmed': 'Couleur des carrés confirmés', 'gsm.colWorked': 'Couleur des carrés contactés (non confirmés)', 'gsm.colReset': 'Revenir aux couleurs du thème', 'gsm.refresh': 'Recompter depuis le journal', 'gsm.count': '{n} carrés · {c} confirmés',
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que sil a été collecté près de chez toi. Plus petit est plus local, mais laisse moins de récepteurs à l’écoute — trop petit, la veille na plus rien à observer. 300 km emprunte les oreilles de toute une région ; 100 km convient au 2 m, où un conduit est étroit.',
'bo.open': 'ouvert', 'bo.liveTip': '{band} est ouvert — {n} stations, ~{km} km, {sector}{season}. Cliquer pour le bandmap.', 'bo.enable': 'Surveiller les ouvertures de bande', 'bo.enableHint': "(10, 12, 6, 4 et 2 m. Activer ajoute les deux nœuds RBN et souscrit au flux PSK Reporter — la détection a besoin de bien plus d oreilles qu un cluster ne peut en fournir.)", 'bo.feedUp': 'Flux PSK Reporter actif — {n} décodages vus', 'bo.feedDown': 'Flux PSK Reporter inactif — il faut ton locator, et un instant pour se connecter', 'clu.workedSameSlot': 'Déjà contacté seulement sur le même slot',
@@ -1023,7 +1023,7 @@ const fr: Dict = {
'wbg.awardTip': '{name} — référence comptée pour ce QSO', 'wbg.typeCall': 'Saisissez un indicatif dans la barre pour voir les contacts précédents.', 'wbg.checking': 'vérification…', 'wbg.new': 'NOUVEAU', 'wbg.noPriorPre': 'Aucun QSO précédent avec ', 'wbg.noPriorPost': '.', 'wbg.workedBefore': 'Déjà contacté', 'wbg.first': 'Premier :', 'wbg.last': 'Dernier :', 'wbg.dxcc': 'DXCC :', 'wbg.entityQsos': '{n} QSO avec cette entité', 'wbg.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'wbg.clearFilters': 'Effacer les filtres', 'wbg.columns': 'Colonnes', 'wbg.olderQsos': '+ {n} QSO plus anciens (non affichés — limités pour la performance)', 'wbg.pickerTitle': 'Colonnes « Déjà contacté »', 'wbg.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau « Déjà contacté ».', 'wbg.allGroups': 'Tous les groupes :', 'wbg.all': 'tout', 'wbg.none': 'aucun', 'wbg.grpAwards': 'Diplômes', 'wbg.resetDefaults': 'Réinitialiser', 'wbg.done': 'Terminé',
'chn.title': 'Chasse au nouveau', 'chn.close': 'Masquer le panneau', 'chn.filterHint': 'Afficher ou masquer ce type', 'chn.allFiltered': 'Tout ce qui est entendu est filtré — réactivez une catégorie ci-dessus.', 'chn.toggle': 'Chasse au nouveau', 'chn.count': '{n} entendus', 'chn.loading': 'En attente du flux…', 'chn.empty': 'Rien de nouveau décodé près de vous pour le moment.', 'chn.digitalOnly': 'PSK Reporter — modes numériques uniquement, entendus à moins de ~300 km.', 'chn.option': 'Chasse au nouveau (PSK Reporter)', 'chn.optionHelp': 'Liste les stations décodées près de chez vous qui sont nouvelles par rapport à votre log — entité, bande, mode, créneau, préfixe ou carré. Modes numériques uniquement.', 'chn.show': 'Panneau chasse au nouveau',
'clg2.allFiltered': '{n} spots reçus, aucun affiché — vos filtres les masquent tous.', 'clg2.activeFilters': 'Actifs :', 'clg2.clearAllFilters': 'Effacer tous les filtres', 'clg2.fBandLock': 'bande verrouillée sur la radio', 'clg2.fBands': 'bandes {list}', 'clg2.fModeLock': 'mode verrouillé sur la radio', 'clg2.fModes': 'modes {list}', 'clg2.fStatus': 'pastilles de statut', 'clg2.fHideWorked': 'masquer les contactés', 'clg2.fLotwOnly': 'utilisateurs LoTW uniquement', 'clg2.fSpotterCont': 'continent du spotteur', 'clg2.fSource': 'un seul nœud source', 'clg2.fSearch': 'recherche « {q} »',
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
'clg2.c.time': 'Heure', 'clg2.c.call': 'Indicatif', 'clg2.c.status': 'Statut', 'clg2.c.pota': 'POTA', 'clg2.c.sota': 'SOTA', 'clg2.c.freq': 'Fréq', 'clg2.c.band': 'Bande', 'clg2.c.mode': 'Mode', 'clg2.c.pfx': 'Préf.', 'clg2.c.cqz': 'Zone CQ', 'clg2.h.cqz': 'CQZ', 'clg2.c.ituz': 'Zone ITU', 'clg2.h.ituz': 'ITU', 'clg2.c.distance_km': 'Distance (km)', 'clg2.h.distance_km': 'Dist km', 'clg2.c.sp_deg': 'Chemin court (°)', 'clg2.h.sp_deg': 'CC°', 'clg2.c.lp_deg': 'Chemin long (°)', 'clg2.h.lp_deg': 'CL°', 'clg2.c.country': 'Pays', 'clg2.c.continent': 'Continent', 'clg2.h.continent': 'Cont', 'clg2.c.spotter': 'Spotter', 'clg2.c.source': 'Source', 'clg2.c.locator': 'Locator du spotter', 'clg2.h.locator': 'Loc spotter', 'clg2.c.county': 'Comté US', 'clg2.tipNewCounty': 'NOUVEAU COMTÉ — jamais contacté', 'clg2.tipNewPfx': "NOUVEAU PRÉFIXE — ce préfixe WPX n'a jamais été contacté", 'clg2.c.comment': 'Commentaire', 'clg2.c.received_at': 'Reçu le', 'clg2.h.received_at': 'Reçu UTC', 'clg2.c.raw': 'Brut', 'clg2.newDxcc': 'NOUV DXCC', 'clg2.newBandMode': 'NOUV B+M', 'clg2.newBand': 'NOUV BANDE', 'clg2.newMode': 'NOUV MODE', 'clg2.newSlot': 'NOUV SLOT', 'clg2.newCall': 'CALL NEUF', 'clg2.wkdCall': 'DÉJÀ QSO', 'clg2.newCounty': 'NOUV CTY', 'clg2.newGridUnconf': 'GRID?', 'clg2.newState': 'Nouvel État', 'clg2.newGrid': 'NOUV GRID', 'clg2.c.grid': 'Grid', 'clg2.tipNewGrid': 'NOUVEAU GRID — ce carré n a jamais été contacté (grid entendu dans un CQ sur le lien UDP)', 'clg2.newPfx': "NOUVEAU PFX", 'clg2.newPota': 'NOUV POTA', 'clg2.tipNewDxcc': 'NOUVEAU DXCC : {country}', 'clg2.tipWorkedCall': 'Indicatif déjà contacté', 'clg2.tipNewBandMode': 'NOUVELLE BANDE ET NOUVEAU MODE pour cette entité — aucun des deux na été fait avec elle', 'clg2.tipNewBand': 'NOUVELLE BANDE pour cette entité', 'clg2.tipNewSlotBand': 'NOUVEAU SLOT (mode pas encore contacté sur cette bande)', 'clg2.tipNewMode': 'NOUVEAU MODE (ce mode jamais contacté sur cette entité)', 'clg2.tipNewSlot': 'NOUVEAU SLOT (cette bande+mode pas encore contactée)', 'clg2.tipNewCall': "CALL NEUF — cet indicatif n a jamais été contacté sur cette bande et ce mode (l entité, si)", 'clg2.tipPota': 'POTA — {name}', 'clg2.grpSpot': 'Spot', 'clg2.grpGeo': 'Géo', 'clg2.clearFiltersTitle': 'Effacer tous les filtres de colonne', 'clg2.clearFilters': 'Effacer les filtres', 'clg2.newSpots': '{n} nouveaux spots — cliquer pour reprendre', 'clg2.columns': 'Colonnes', 'clg2.pickerTitle': 'Colonnes du cluster', 'clg2.pickerDesc': 'Choisissez les colonnes à afficher dans le tableau du cluster.', 'clg2.allGroups': 'Tous les groupes :', 'clg2.all': 'tout', 'clg2.none': 'aucun', 'clg2.resetDefaults': 'Réinitialiser', 'clg2.done': 'Terminé',
// Périphériques audio et manipulateur vocal (Préférences → Périphériques audio).
'aud.refreshDevices': 'Actualiser les périphériques', 'aud.fromRadio': 'Depuis la radio (entrée RX)', 'aud.toRadio': 'Vers la radio (sortie TX)', 'aud.recMic': "Micro d'enregistrement", 'aud.listening': 'Écoute (pré-écoute)',
'aud.phFromRadio': 'Sortie audio du poste → entrée carte son', 'aud.phToRadio': 'Sortie carte son → entrée micro/data du poste', 'aud.phRecMic': 'Votre microphone (enregistrer les messages vocaux)', 'aud.phListening': 'Haut-parleurs locaux pour la pré-écoute',
+2 -1
View File
@@ -18,7 +18,7 @@
// grid magenta — the last hue in the categorical set that is not already
// spoken for here and does not read as a status; a grid is
// never urgent the way a new entity is
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid';
export type SpotMarkerKey = 'new_pota' | 'new_county' | 'new_pfx' | 'worked_call' | 'new_grid' | 'new_state';
export type SpotMarker = {
key: SpotMarkerKey;
@@ -32,6 +32,7 @@ export const SPOT_MARKERS: SpotMarker[] = [
{ key: 'new_county', colour: 'var(--chart-5)', labelKey: 'clg2.newCounty' },
{ key: 'new_pfx', colour: 'var(--caution)', labelKey: 'clg2.newPfx' },
{ key: 'new_grid', colour: 'var(--chart-7)', labelKey: 'clg2.newGrid' },
{ key: 'new_state', colour: 'var(--chart-3)', labelKey: 'clg2.newState' },
{ key: 'worked_call', colour: 'var(--info)', labelKey: 'clg2.wkdCall' },
];
+7
View File
@@ -1086,3 +1086,10 @@
.overflow-scroll {
overscroll-behavior: contain;
}
/* The ground beside the planet, on every map: Leaflet paints its container a
hard-coded light grey (#ddd), which reads as a broken tile against any
theme. The surround follows the theme's own card surface instead. */
.leaflet-container {
background: var(--card) !important;
}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.27.3';
export const APP_VERSION = '0.27.4';
// Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO';
+4
View File
@@ -452,6 +452,8 @@ export function GetChaseNewGrids():Promise<boolean>;
export function GetChaseNewSpots():Promise<Array<main.ChaseNewSpot>>;
export function GetChaseSettings():Promise<main.ChaseSettings>;
export function GetChatHistory(arg1:number):Promise<Array<main.ChatMessage>>;
export function GetClublogCtyInfo():Promise<main.ClublogCtyInfo>;
@@ -1028,6 +1030,8 @@ export function SaveCATSettings(arg1:main.CATSettings):Promise<void>;
export function SaveCabrilloFile():Promise<string>;
export function SaveChaseSettings(arg1:main.ChaseSettings):Promise<void>;
export function SaveClusterServer(arg1:cluster.ServerConfig):Promise<cluster.ServerConfig>;
export function SaveEmailSettings(arg1:main.EmailSettings):Promise<void>;
+8
View File
@@ -842,6 +842,10 @@ export function GetChaseNewSpots() {
return window['go']['main']['App']['GetChaseNewSpots']();
}
export function GetChaseSettings() {
return window['go']['main']['App']['GetChaseSettings']();
}
export function GetChatHistory(arg1) {
return window['go']['main']['App']['GetChatHistory'](arg1);
}
@@ -1994,6 +1998,10 @@ export function SaveCabrilloFile() {
return window['go']['main']['App']['SaveCabrilloFile']();
}
export function SaveChaseSettings(arg1) {
return window['go']['main']['App']['SaveChaseSettings'](arg1);
}
export function SaveClusterServer(arg1) {
return window['go']['main']['App']['SaveClusterServer'](arg1);
}
+24
View File
@@ -2430,6 +2430,20 @@ export namespace main {
this.at = source["at"];
}
}
export class ChaseSettings {
mode: string;
sources: string[];
static createFrom(source: any = {}) {
return new ChaseSettings(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.mode = source["mode"];
this.sources = source["sources"];
}
}
export class ChatMessage {
id: number;
operator: string;
@@ -3957,6 +3971,11 @@ export namespace main {
new_county: boolean;
county?: string;
state?: string;
new_state: boolean;
unconf_status?: boolean;
unconf_pfx?: boolean;
unconf_cty?: boolean;
unconf_state?: boolean;
new_pota: boolean;
grid?: string;
new_grid: boolean;
@@ -3983,6 +4002,11 @@ export namespace main {
this.new_county = source["new_county"];
this.county = source["county"];
this.state = source["state"];
this.new_state = source["new_state"];
this.unconf_status = source["unconf_status"];
this.unconf_pfx = source["unconf_pfx"];
this.unconf_cty = source["unconf_cty"];
this.unconf_state = source["unconf_state"];
this.new_pota = source["new_pota"];
this.grid = source["grid"];
this.new_grid = source["new_grid"];
+88 -3
View File
@@ -2657,10 +2657,20 @@ type EntitySlot struct {
// Modes/Slots key — pass GroupDigitalMode to collapse all digital modes into
// one bucket. Callers must normalise their lookup mode the same way.
func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string) (map[int]*EntitySlot, error) {
return r.EntitySlotMapPred(ctx, keyFor, normMode, "")
}
// EntitySlotMapPred is EntitySlotMap over only the QSOs matching pred — the
// confirmed-only ledger the "chase unconfirmed too" mode judges against.
func (r *Repo) EntitySlotMapPred(ctx context.Context, keyFor func(call string, storedDXCC int, country string) int, normMode func(string) string, pred string) (map[int]*EntitySlot, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT callsign, coalesce(dxcc,0), lower(coalesce(country,'')), lower(band), upper(mode) FROM qso
WHERE band IS NOT NULL AND band != ''
AND mode IS NOT NULL AND mode != ''`)
AND mode IS NOT NULL AND mode != ''`+where)
if err != nil {
return nil, err
}
@@ -2710,8 +2720,18 @@ func (r *Repo) EntitySlotMap(ctx context.Context, keyFor func(call string, store
// One pass, used by the cluster spot colouring to flag "already worked this
// exact call" regardless of band/mode — Log4OM/RUMlogNG-style call highlight.
func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error) {
return r.WorkedCallsignsPred(ctx, "")
}
// WorkedCallsignsPred restricts the callsign ledger to QSOs matching pred —
// the confirmed-prefix (WPX) chase reads its prefixes off this set.
func (r *Repo) WorkedCallsignsPred(ctx context.Context, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`)
`SELECT DISTINCT upper(callsign) FROM qso WHERE callsign != ''`+where)
if err != nil {
return nil, err
}
@@ -2732,9 +2752,18 @@ func (r *Repo) WorkedCallsigns(ctx context.Context) (map[string]struct{}, error)
// the award package here). Only US-entity QSOs (DXCC 291/110/6) with a county
// are considered. Empty keys (unresolvable state/county) are skipped.
func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty string) string) (map[string]struct{}, error) {
return r.WorkedCountyKeysPred(ctx, keyFn, "")
}
// WorkedCountyKeysPred restricts the county ledger to QSOs matching pred.
func (r *Repo) WorkedCountyKeysPred(ctx context.Context, keyFn func(state, cnty string) string, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT COALESCE(state,''), COALESCE(cnty,'') FROM qso
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`)
WHERE dxcc IN (291,110,6) AND cnty IS NOT NULL AND cnty != ''`+where)
if err != nil {
return nil, err
}
@@ -2752,6 +2781,38 @@ func (r *Repo) WorkedCountyKeys(ctx context.Context, keyFn func(state, cnty stri
return out, rows.Err()
}
// WorkedStateKeys returns the set of US states already worked, uppercased —
// the WAS ledger the cluster and decode panels judge NEW STATE against.
func (r *Repo) WorkedStateKeys(ctx context.Context) (map[string]struct{}, error) {
return r.WorkedStateKeysPred(ctx, "")
}
// WorkedStateKeysPred restricts the state ledger to QSOs matching pred.
func (r *Repo) WorkedStateKeysPred(ctx context.Context, pred string) (map[string]struct{}, error) {
where := ""
if pred != "" {
where = " AND " + pred
}
rows, err := r.db.QueryContext(ctx,
`SELECT DISTINCT UPPER(COALESCE(state,'')) FROM qso
WHERE dxcc IN (291,110,6) AND state IS NOT NULL AND state != ''`+where)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]struct{}, 64)
for rows.Next() {
var st string
if err := rows.Scan(&st); err != nil {
return nil, err
}
if st != "" {
out[st] = struct{}{}
}
}
return out, rows.Err()
}
// CountQSLViaRouting counts the QSOs whose qsl_via holds a routing method
// instead of a manager, per isRouting.
//
@@ -3362,6 +3423,30 @@ type SlotStats struct {
// only way those two agree.
const ConfirmedValues = "('Y','V')"
// ConfirmSourcesPredicate builds the SQL that says "this QSO is confirmed",
// from the operator's chosen sources — the same choice the award engine's
// fixed three (LoTW, card, eQSL) used to hard-code. QRZ.com's confirmation is
// its download status; HRDLog has no confirmation field in ADIF at all, which
// is why it cannot be offered. Empty input falls back to the classic three.
func ConfirmSourcesPredicate(sources []string) string {
cols := map[string]string{
"lotw": "lotw_rcvd",
"card": "qsl_rcvd",
"eqsl": "eqsl_rcvd",
"qrz": "qrzcom_qso_download_status",
}
var parts []string
for _, src := range sources {
if col, ok := cols[strings.ToLower(strings.TrimSpace(src))]; ok {
parts = append(parts, col+" IN "+ConfirmedValues)
}
}
if len(parts) == 0 {
return "(lotw_rcvd IN " + ConfirmedValues + " OR qsl_rcvd IN " + ConfirmedValues + " OR eqsl_rcvd IN " + ConfirmedValues + ")"
}
return "(" + strings.Join(parts, " OR ") + ")"
}
// GetSlotStats computes the worked/confirmed slot and DXCC tallies in one pass.
// "Confirmed" = LoTW or paper QSL received (the award-valid sources).
func (r *Repo) GetSlotStats(ctx context.Context) (SlotStats, error) {
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const (
// appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.27.3"
appVersion = "0.27.4"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project.