Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd8719ce99 | ||
|
|
5e38319379 | ||
|
|
7e7ad50f60 | ||
|
|
0f4e31853b | ||
|
|
c222468021 | ||
|
|
2fb4a4d0ce | ||
|
|
e4828482c4 | ||
|
|
8acfda4e42 | ||
|
|
db24c38e63 | ||
|
|
06868e9ed1 | ||
|
|
3ed643e072 | ||
|
|
f0f9898f7c | ||
|
|
73cae855ed | ||
|
|
de3a115ca6 | ||
|
|
1d7633484b | ||
|
|
5ee0ade54b | ||
|
|
e637f0814d | ||
|
|
1a169fdb4f | ||
|
|
3dc31697cd |
@@ -11576,14 +11576,18 @@ func (a *App) runManualUpload(svc extsvc.Service, ids []int64, cfg extsvc.Extern
|
|||||||
// with award-style NEW flags computed against the log's prior confirmations.
|
// with award-style NEW flags computed against the log's prior confirmations.
|
||||||
type ConfirmationItem struct {
|
type ConfirmationItem struct {
|
||||||
Callsign string `json:"callsign"`
|
Callsign string `json:"callsign"`
|
||||||
QSODate string `json:"qso_date"` // ISO UTC
|
// Station is the callsign the QSO was made UNDER, which the download can now
|
||||||
Band string `json:"band"`
|
// span ("All my callsigns"): a list mixing F4BPO, F4BPO/P and TM2Q says
|
||||||
Mode string `json:"mode"`
|
// nothing useful unless each line says which of them it belongs to.
|
||||||
Country string `json:"country"`
|
Station string `json:"station"`
|
||||||
NewDXCC bool `json:"new_dxcc"`
|
QSODate string `json:"qso_date"` // ISO UTC
|
||||||
NewBand bool `json:"new_band"`
|
Band string `json:"band"`
|
||||||
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
Mode string `json:"mode"`
|
||||||
NewSlot bool `json:"new_slot"`
|
Country string `json:"country"`
|
||||||
|
NewDXCC bool `json:"new_dxcc"`
|
||||||
|
NewBand bool `json:"new_band"`
|
||||||
|
NewMode bool `json:"new_mode"` // new mode CLASS (Phone/CW/Digital) for the entity
|
||||||
|
NewSlot bool `json:"new_slot"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
// GetSlotStats returns the worked/confirmed slot + DXCC tallies for the QSL
|
||||||
@@ -12365,6 +12369,7 @@ func (a *App) runDownloadConfirmations(ctx context.Context, svc extsvc.Service,
|
|||||||
}
|
}
|
||||||
it := ConfirmationItem{
|
it := ConfirmationItem{
|
||||||
Callsign: q.Callsign,
|
Callsign: q.Callsign,
|
||||||
|
Station: strings.ToUpper(strings.TrimSpace(rec["station_callsign"])),
|
||||||
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
QSODate: q.QSODate.UTC().Format(time.RFC3339),
|
||||||
Band: q.Band,
|
Band: q.Band,
|
||||||
Mode: q.Mode,
|
Mode: q.Mode,
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ func (a *App) GetKenwoodState() cat.KenwoodTXState {
|
|||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetKenwoodPanelMode sets the operating mode from the panel's mode row —
|
||||||
|
// CW / USB / LSB / DATA (soundcard, DT0) / RTTY (FSK D, DT2).
|
||||||
|
func (a *App) SetKenwoodPanelMode(mode string) error {
|
||||||
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPanelMode(mode) })
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) SetKenwoodPower(w int) error {
|
func (a *App) SetKenwoodPower(w int) error {
|
||||||
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodPower(w) })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,34 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.26.24",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"TCI (SunSDR): the meter subscription is renewed when the radio announces it is ready — sent only at connect, it could fall inside the initial state dump and be ignored, which left the transmit power and SWR empty. The log now also records the subscription and the first sensor frames, so a report can tell “the radio never sends them” from “they arrived and were dropped”.",
|
||||||
|
"Sending a spot while in split now pre-fills the RX frequency — where the station actually is — instead of the TX frequency, which spotted the pile-up five up from the DX.",
|
||||||
|
"Cluster: NEW SLOT gets its own colour — the sky cyan the panadapter palette already uses for it, clearly apart from POTA green and the yellows. The NEW SLOT and NEW COUNTY filter chips now wear the same colours as the badges they select, and the NEW CALL filter works even when the slot-highlight display option is off.",
|
||||||
|
"Elecraft console: a mode row — CW, USB, LSB, DATA and DATA RTTY. The two DATA buttons set the K3’s submode as well (DATA A for FT8/FT4, FSK D for RTTY): switching to DATA by mode alone kept whatever submode the last session left, which is how a K3 “in DATA” keys FT8 with no audio.",
|
||||||
|
"DX Cluster settings: “I chase POTA” and “I chase SOTA”, on by default. Unticked, the NEW POTA badge, colour and filter disappear — a new-band + new-POTA spot reads NEW BAND alone — and the reference columns stay empty."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"TCI (SunSDR) : l'abonnement aux mesures est renouvelé quand la radio annonce qu'elle est prête — envoyé seulement à la connexion, il pouvait tomber pendant l'envoi initial de l'état et être ignoré, laissant la puissance et le ROS vides en émission. Le journal enregistre aussi l'abonnement et les premières trames de mesure, pour distinguer « la radio ne les envoie jamais » de « elles arrivaient et étaient perdues ».",
|
||||||
|
"Envoyer un spot en split préremplit désormais la fréquence RX — là où la station se trouve réellement — au lieu de la fréquence TX, qui spottait le pile-up cinq au-dessus du DX.",
|
||||||
|
"Cluster : NOUVEAU SLOT reçoit sa propre couleur — le cyan ciel que la palette du panadapter lui donne déjà, bien distinct du vert POTA et des jaunes. Les puces de filtre NOUVEAU SLOT et NOUVEAU COMTÉ portent désormais les couleurs des badges qu'elles sélectionnent, et le filtre NOUVEAU CALL fonctionne même quand l'option de surlignage par slot est désactivée.",
|
||||||
|
"Console Elecraft : une rangée de modes — CW, USB, LSB, DATA et DATA RTTY. Les deux boutons DATA règlent aussi le sous-mode du K3 (DATA A pour FT8/FT4, FSK D pour le RTTY) : passer en DATA par le seul mode gardait le sous-mode de la session précédente, et un K3 « en DATA » manipulait le FT8 sans audio.",
|
||||||
|
"Réglages DX Cluster : « Je chasse le POTA » et « Je chasse le SOTA », cochés par défaut. Décochés, le badge, la couleur et le filtre NOUVEAU POTA disparaissent — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE — et les colonnes de références restent vides."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.26.23",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"LoTW: the downloaded confirmations list gains a Station column, so a list spanning several callsigns says which one each confirmation belongs to. Shown only when the report actually carries more than the one station.",
|
||||||
|
"The band maps follow your IARU region (Settings → General): Region 2’s 40 m runs to 7300 kHz with phone from 7125 and its 80 m to 4000, Region 3’s 80 m to 3900 — the band edges and the CW/digital/phone shading all adjust."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"LoTW : la liste des confirmations téléchargées gagne une colonne Station, pour savoir à quel indicatif appartient chaque confirmation quand le téléchargement en couvre plusieurs. Affichée seulement si le rapport en contient effectivement.",
|
||||||
|
"Les cartes de bande suivent votre région IARU (Réglages → Général) : le 40 m de la Région 2 va jusqu'à 7300 kHz avec la phonie dès 7125 et son 80 m jusqu'à 4000, le 80 m de la Région 3 jusqu'à 3900 — les limites de bande et les zones CW/numérique/phonie s'ajustent."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.26.22",
|
"version": "0.26.22",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+31
-10
@@ -98,7 +98,7 @@ import { ExportFieldsDialog } from '@/components/ExportFieldsDialog';
|
|||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotModeCategory, spotStatusKey } from '@/lib/spot';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
import { applySpotDisplay, chasePota, readSpotDisplayOptions, spotIsWorked, SPOT_DISPLAY_OPTIONS_EXPOSED } from '@/lib/spotDisplay';
|
||||||
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
|
import { AnswerDecode, HaltDecodeTx, LogUIError, FlexTXOnBand, GetMatrixColors, GetRotorPresets, GetRowColors, GetSpotTTLMinutes, GetSpotMax, IsNewUSCounty } from '../wailsjs/go/main/App';
|
||||||
import { applyMatrixColors } from '@/lib/matrixColors';
|
import { applyMatrixColors } from '@/lib/matrixColors';
|
||||||
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
import { WorkedBeforeGrid } from '@/components/WorkedBeforeGrid';
|
||||||
@@ -5870,6 +5870,13 @@ export default function App() {
|
|||||||
// worked call still matches its own entity status too (new-band/new-slot),
|
// worked call still matches its own entity status too (new-band/new-slot),
|
||||||
// so it stays visible under those chips.
|
// so it stays visible under those chips.
|
||||||
const matches = (st !== 'worked' && clusterStatusFilter.has(st))
|
const matches = (st !== 'worked' && clusterStatusFilter.has(st))
|
||||||
|
// NEW CALL is a FACT about the callsign (never worked on this band and
|
||||||
|
// mode), surfaced as a status only by the slot-highlight option. The
|
||||||
|
// chip filters on the fact itself, so it works whether or not that
|
||||||
|
// display option is on — worked_slot is computed whenever either
|
||||||
|
// slot option is enabled.
|
||||||
|
|| (clusterStatusFilter.has('new-call') && e?.worked_slot === false
|
||||||
|
&& (!e?.status || e?.status === 'worked' || e?.status === 'new-call'))
|
||||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
||||||
@@ -5976,12 +5983,20 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity';
|
const F_CHIP = 'px-1.5 py-[3px] rounded-md border text-[10px] font-bold tracking-wider transition-opacity';
|
||||||
const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void) => (
|
const fChip = (key: string, label: string, cls: string, on: boolean, toggle: () => void, style?: React.CSSProperties, title?: string) => (
|
||||||
<button key={key} type="button" onClick={toggle}
|
<button key={key} type="button" onClick={toggle} title={title}
|
||||||
className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)}>
|
className={cn(F_CHIP, on ? cls : `${cls} opacity-40 hover:opacity-80`)} style={style}>
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
// chipStyle builds a filter chip in an arbitrary colour — for the statuses
|
||||||
|
// whose grid badge is not one of the semantic tokens the chip classes cover.
|
||||||
|
// A filter that does not look like what it selects has to be learned twice.
|
||||||
|
const chipStyle = (c: string): React.CSSProperties => ({
|
||||||
|
color: c,
|
||||||
|
borderColor: `color-mix(in srgb, ${c} 45%, transparent)`,
|
||||||
|
background: `color-mix(in srgb, ${c} 14%, transparent)`,
|
||||||
|
});
|
||||||
|
|
||||||
const renderClusterFilters = () => (
|
const renderClusterFilters = () => (
|
||||||
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
<div className="w-56 shrink-0 border-l border-border/60 flex flex-col min-h-0 bg-muted/10">
|
||||||
@@ -6097,14 +6112,14 @@ export default function App() {
|
|||||||
{ k: 'new-band-mode' as SpotFilterKey, label: 'NEW B+M', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
{ k: 'new-band-mode' as SpotFilterKey, label: 'NEW B+M', cls: 'bg-danger-muted text-danger-muted-foreground border-danger-border' },
|
||||||
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
{ k: 'new-band' as SpotFilterKey, label: 'NEW BAND', cls: 'bg-warning-muted text-warning-muted-foreground border-warning-border' },
|
||||||
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
{ k: 'new-mode' as SpotFilterKey, label: 'NEW MODE', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
{ k: 'new-slot' as SpotFilterKey, label: 'NEW SLOT', cls: 'border', style: chipStyle('#5AC8FA') },
|
||||||
// NEW CALL is about the CALLSIGN, not the entity: never worked on this
|
// NEW CALL is about the CALLSIGN, not the entity: never worked on this
|
||||||
// band and mode. Only appears when the slot-highlight option is on.
|
// band and mode. Only appears when the slot-highlight option is on.
|
||||||
{ k: 'new-call' as SpotFilterKey, label: 'NEW CALL', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
{ k: 'new-call' as SpotFilterKey, label: 'NEW CALL', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
// Same colours as the badges in the grid — a filter that does not
|
// Same colours as the badges in the grid — a filter that does not
|
||||||
// look like what it selects has to be learned twice.
|
// look like what it selects has to be learned twice.
|
||||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'border', style: chipStyle('var(--chart-5)') },
|
||||||
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
// Only ever set for a station this receiver decoded over the UDP link.
|
// Only ever set for a station this receiver decoded over the UDP link.
|
||||||
{ k: 'new-grid' as SpotFilterKey, label: 'NEW GRID', cls: 'bg-muted text-foreground border-border' },
|
{ k: 'new-grid' as SpotFilterKey, label: 'NEW GRID', cls: 'bg-muted text-foreground border-border' },
|
||||||
@@ -6112,8 +6127,9 @@ export default function App() {
|
|||||||
// worked spots; the separate "Hide worked" checkbox drops them — they
|
// worked spots; the separate "Hide worked" checkbox drops them — they
|
||||||
// are opposite controls, so don't use both at once.
|
// are opposite controls, so don't use both at once.
|
||||||
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
{ k: 'worked' as SpotFilterKey, label: 'WORKED', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
||||||
]).map((s) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
]).filter((s: any) => s.k !== 'new-pota' || chasePota())
|
||||||
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; })))}
|
.map((s: any) => fChip(s.k, s.label, s.cls, clusterStatusFilter.has(s.k),
|
||||||
|
() => setClusterStatusFilter((cur) => { const n = new Set(cur); if (n.has(s.k)) n.delete(s.k); else n.add(s.k); return n; }), s.style))}
|
||||||
</div>,
|
</div>,
|
||||||
clusterStatusFilter.size > 0 ? (
|
clusterStatusFilter.size > 0 ? (
|
||||||
<button type="button" onClick={() => setClusterStatusFilter(new Set())}
|
<button type="button" onClick={() => setClusterStatusFilter(new Set())}
|
||||||
@@ -8652,9 +8668,14 @@ export default function App() {
|
|||||||
// freqMhz display string, which the manual-edit freeze / field locks can
|
// freqMhz display string, which the manual-edit freeze / field locks can
|
||||||
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
|
// leave stale (that dropped the sub-kHz: on 14134.5 the frozen "14.134"
|
||||||
// string spotted 14134). Fall back to the entry field, then the last QSO.
|
// string spotted 14134). Fall back to the entry field, then the last QSO.
|
||||||
|
//
|
||||||
|
// The RX frequency when split, deliberately: a spot names where the
|
||||||
|
// STATION is, and in split that is where we listen — freq_hz is where
|
||||||
|
// we transmit, and spotting a 5-up pile-up's TX frequency sent the
|
||||||
|
// whole cluster five kHz above the DX.
|
||||||
defaultFreqKHz={
|
defaultFreqKHz={
|
||||||
catState.connected && (catState.freq_hz ?? 0) > 0
|
catState.connected && ((catState.split && (catState.freq_rx_hz ?? 0) > 0 ? catState.freq_rx_hz : catState.freq_hz) ?? 0) > 0
|
||||||
? Math.round(((catState.freq_hz ?? 0) / 1000) * 10) / 10
|
? Math.round((((catState.split && (catState.freq_rx_hz ?? 0) > 0 ? catState.freq_rx_hz : catState.freq_hz) ?? 0) / 1000) * 10) / 10
|
||||||
: parseFloat(freqMhz) > 0
|
: parseFloat(freqMhz) > 0
|
||||||
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
? Math.round(parseFloat(freqMhz) * 1000 * 10) / 10
|
||||||
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
: (qsos[0]?.freq_hz ? Math.round((qsos[0].freq_hz / 1000) * 10) / 10 : 0)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
import { Minus, Plus, Crosshair, X, PanelLeft, PanelRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { bandRange, bandSegments, subscribeIaruRegion, type SegMode } from '@/lib/bandplan';
|
||||||
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
import { spotStatusKey, inferSpotMode, spotModeCategory } from '@/lib/spot';
|
||||||
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
import { SPOT_MARKERS, activeMarkers } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
@@ -114,59 +115,26 @@ interface Props {
|
|||||||
keyNav?: boolean;
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
// Band edges and mode segments come from lib/bandplan, which knows the
|
||||||
'160m': [1800, 2000],
|
// operator's IARU region (Settings → General): a Region 2 operator's 40 m runs
|
||||||
'80m': [3500, 3800],
|
// to 7300 with phone from 7125, and drawing Region 1's plan under their spots
|
||||||
'60m': [5350, 5450],
|
// cut the top 100 kHz of the band off the map.
|
||||||
'40m': [7000, 7200],
|
//
|
||||||
'30m': [10100, 10150],
|
// Sub-band shading colours: these are IDENTITIES (which mode the segment is
|
||||||
'20m': [14000, 14350],
|
// for), not states, so they take categorical hues — never the status tokens.
|
||||||
'17m': [18068, 18168],
|
// All three are drawn from the cool end of the categorical order and laid down
|
||||||
'15m': [21000, 21450],
|
// at low opacity, so the band plan stays background context while the warm
|
||||||
'12m': [24890, 24990],
|
// spot pills keep the foreground. Checked with the palette validator in both
|
||||||
'10m': [28000, 29700],
|
// themes (violet failed the dark protan step; magenta clears every check).
|
||||||
'6m': [50000, 50500],
|
const SEG_COLOR: Record<SegMode, string> = {
|
||||||
'4m': [70000, 70500],
|
cw: 'var(--chart-7)', // magenta
|
||||||
'2m': [144000, 146000],
|
digi: 'var(--chart-1)', // blue
|
||||||
'70cm': [430000, 440000],
|
phone: 'var(--chart-2)', // aqua
|
||||||
};
|
};
|
||||||
|
|
||||||
// Sub-band shading: CW / digital / phone.
|
|
||||||
//
|
|
||||||
// These are IDENTITIES (which mode the segment is for), not states, so they take
|
|
||||||
// categorical hues — never the status tokens. They used to use success / info /
|
|
||||||
// warning, which collided head-on with the spot pills drawn ON TOP of them: amber
|
|
||||||
// is "new band" in this very component's legend, so the whole SSB portion read as
|
|
||||||
// a giant "new band" wash. Status colours are reserved for status.
|
|
||||||
//
|
|
||||||
// All three are drawn from the COOL end of the categorical order and laid down at
|
|
||||||
// low opacity, so the band plan stays background context while the warm spot pills
|
|
||||||
// keep the foreground to themselves.
|
|
||||||
// Checked with the palette validator in BOTH themes. Violet was the first pick
|
|
||||||
// for CW and failed on the dark steps — violet and blue land 1.9 ΔE apart for a
|
|
||||||
// protan reader there, i.e. the same colour. Magenta clears every check on both
|
|
||||||
// surfaces (worst adjacent pair 13.0 light / 15.9 dark).
|
|
||||||
const SEG_CW = 'var(--chart-7)'; // magenta
|
|
||||||
const SEG_DIGI = 'var(--chart-1)'; // blue
|
|
||||||
const SEG_PHONE = 'var(--chart-2)'; // aqua
|
|
||||||
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
// A band-plan wash is CONTEXT, not data: it must stay under the spot pills that
|
||||||
// are read on top of it.
|
// are read on top of it.
|
||||||
const SEG_OPACITY = 0.13;
|
const SEG_OPACITY = 0.13;
|
||||||
|
|
||||||
const SEGMENT_COLORS: Record<string, [number, number, string][]> = {
|
|
||||||
'160m': [[1800, 1838, SEG_CW], [1838, 1840, SEG_DIGI], [1840, 2000, SEG_PHONE]],
|
|
||||||
'80m': [[3500, 3580, SEG_CW], [3580, 3600, SEG_DIGI], [3600, 3800, SEG_PHONE]],
|
|
||||||
'60m': [[5350, 5450, SEG_PHONE]],
|
|
||||||
'40m': [[7000, 7040, SEG_CW], [7040, 7100, SEG_DIGI], [7100, 7200, SEG_PHONE]],
|
|
||||||
'30m': [[10100, 10130, SEG_CW], [10130, 10150, SEG_DIGI]],
|
|
||||||
'20m': [[14000, 14070, SEG_CW], [14070, 14100, SEG_DIGI], [14100, 14350, SEG_PHONE]],
|
|
||||||
'17m': [[18068, 18095, SEG_CW], [18095, 18110, SEG_DIGI], [18110, 18168, SEG_PHONE]],
|
|
||||||
'15m': [[21000, 21070, SEG_CW], [21070, 21150, SEG_DIGI], [21150, 21450, SEG_PHONE]],
|
|
||||||
'12m': [[24890, 24915, SEG_CW], [24915, 24940, SEG_DIGI], [24940, 24990, SEG_PHONE]],
|
|
||||||
'10m': [[28000, 28070, SEG_CW], [28070, 28300, SEG_DIGI], [28300, 29700, SEG_PHONE]],
|
|
||||||
'6m': [[50000, 50100, SEG_CW], [50100, 50500, SEG_PHONE]],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Small coloured dot + label used in the band-map legend strip.
|
// Small coloured dot + label used in the band-map legend strip.
|
||||||
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
function LegendDot({ cls, colour, label }: { cls?: string; colour?: string; label: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -304,13 +272,16 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
// nothing until the next poll happened to hand over a fresh object.
|
// nothing until the next poll happened to hand over a fresh object.
|
||||||
const dispOpts = readSpotDisplayOptions();
|
const dispOpts = readSpotDisplayOptions();
|
||||||
const spotStatus = useMemo(() => {
|
const spotStatus = useMemo(() => {
|
||||||
if (!dispOpts.muteWorked && !dispOpts.slotHighlight) return spotStatusRaw;
|
if (!dispOpts.muteWorked && !dispOpts.slotHighlight && dispOpts.chasePota) return spotStatusRaw;
|
||||||
const out: Record<string, SpotStatusEntry> = {};
|
const out: Record<string, SpotStatusEntry> = {};
|
||||||
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
for (const k of Object.keys(spotStatusRaw)) out[k] = applySpotDisplay(spotStatusRaw[k], dispOpts) as SpotStatusEntry;
|
||||||
return out;
|
return out;
|
||||||
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight]);
|
}, [spotStatusRaw, dispOpts.muteWorked, dispOpts.slotHighlight, dispOpts.chasePota]);
|
||||||
const range = BAND_RANGES[band];
|
// Re-render when the operator changes their IARU region in Settings.
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const [, setRegionTick] = useState(0);
|
||||||
|
useEffect(() => subscribeIaruRegion(() => setRegionTick((n) => n + 1)), []);
|
||||||
|
const range = bandRange(band);
|
||||||
|
const segments = bandSegments(band).map(([a, b, m]) => [a, b, SEG_COLOR[m]] as [number, number, string]);
|
||||||
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
const [zoomIdx, setZoomIdx] = useState(() => readZoom(band));
|
||||||
// The docked map follows the rig, so a band change must bring up THAT band's
|
// The docked map follows the rig, so a band change must bring up THAT band's
|
||||||
// remembered zoom.
|
// remembered zoom.
|
||||||
@@ -794,9 +765,9 @@ export function BandMap({ band, spots, spotStatus: spotStatusRaw, currentFreqHz,
|
|||||||
))}
|
))}
|
||||||
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
{/* Sub-band shading, so the wash behind the pills is never colour-alone. */}
|
||||||
<span className="mx-0.5 opacity-40">|</span>
|
<span className="mx-0.5 opacity-40">|</span>
|
||||||
<LegendDot colour={SEG_CW} label={t("bmp.legendCW")} />
|
<LegendDot colour={SEG_COLOR.cw} label={t("bmp.legendCW")} />
|
||||||
<LegendDot colour={SEG_DIGI} label={t("bmp.legendData")} />
|
<LegendDot colour={SEG_COLOR.digi} label={t("bmp.legendData")} />
|
||||||
<LegendDot colour={SEG_PHONE} label={t("bmp.legendPhone")} />
|
<LegendDot colour={SEG_COLOR.phone} label={t("bmp.legendPhone")} />
|
||||||
</div>
|
</div>
|
||||||
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
<div className="px-3 py-1 text-[9px] text-muted-foreground bg-muted/30 border-t border-border font-mono text-center shrink-0">
|
||||||
{t('bmp.footerHint')}
|
{t('bmp.footerHint')}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
import { cleanSpotter, inferSpotMode, spotStatusKey } from '@/lib/spot';
|
||||||
import { markerColour } from '@/lib/spotMarkers';
|
import { markerColour } from '@/lib/spotMarkers';
|
||||||
import { applySpotDisplay, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
import { applySpotDisplay, chasePota, chaseSota, readSpotDisplayOptions } from '@/lib/spotDisplay';
|
||||||
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
import { loadLocal, loadRemote, saveState, seedLocal, whenGridPrefsReady } from '@/lib/gridPrefs';
|
||||||
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
import { distanceUnit, distanceValue, subscribeDistanceUnit } from '@/lib/units';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
@@ -148,6 +148,12 @@ function statusFor(p: any): SpotStatusEntry | undefined {
|
|||||||
// filled pfx → new prefix filled POTA → new park filled county → new county
|
// filled pfx → new prefix filled POTA → new park filled county → new county
|
||||||
// blue call → already worked (not a novelty, so text only)
|
// blue call → already worked (not a novelty, so text only)
|
||||||
const NEW = 'var(--warning)'; // yellow: something here is new
|
const NEW = 'var(--warning)'; // yellow: something here is new
|
||||||
|
// NEW SLOT gets its own hue. It shared the amber NEW family while the NEW PFX
|
||||||
|
// marker sits in caution yellow — two different facts, two near-identical
|
||||||
|
// colours in the same cell. Sky cyan, the exact colour the panadapter palette
|
||||||
|
// ships for new-slot (#5AC8FA), so the two views tell one story — and clearly
|
||||||
|
// apart from the POTA green the first attempt (aqua) sat next to.
|
||||||
|
const NEWSLOT = '#5AC8FA';
|
||||||
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
const WKD = 'var(--info)'; // blue: this callsign is already in the log
|
||||||
|
|
||||||
// FILLING the cell that carries the fact, rather than only tinting its text.
|
// FILLING the cell that carries the fact, rather than only tinting its text.
|
||||||
@@ -190,9 +196,10 @@ function statusColor(s: SpotStatusEntry | undefined): string | null {
|
|||||||
case 'new-band-mode':
|
case 'new-band-mode':
|
||||||
case 'new-band':
|
case 'new-band':
|
||||||
case 'new-mode':
|
case 'new-mode':
|
||||||
case 'new-slot':
|
|
||||||
case 'new-call':
|
case 'new-call':
|
||||||
return NEW;
|
return NEW;
|
||||||
|
case 'new-slot':
|
||||||
|
return NEWSLOT;
|
||||||
default:
|
default:
|
||||||
return s?.worked_call ? WKD : null;
|
return s?.worked_call ? WKD : null;
|
||||||
}
|
}
|
||||||
@@ -336,7 +343,9 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
group: 'Spot', label: t('clg2.c.pota'), colId: 'pota',
|
||||||
headerName: t('clg2.c.pota'), field: 'pota_ref' as any, width: 92, cellClass: 'font-mono',
|
headerName: t('clg2.c.pota'), width: 92, cellClass: 'font-mono',
|
||||||
|
// Through a valueGetter so the chase switch empties the column live.
|
||||||
|
valueGetter: (p: any) => (chasePota() ? p.data?.pota_ref ?? '' : ''),
|
||||||
defaultVisible: true,
|
defaultVisible: true,
|
||||||
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
cellStyle: (p: any) => (statusFor(p)?.new_pota
|
||||||
? fillStyle(markerColour('new_pota'))
|
? fillStyle(markerColour('new_pota'))
|
||||||
@@ -349,7 +358,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
// which is why the column is off by default rather than an empty column for
|
// which is why the column is off by default rather than an empty column for
|
||||||
// everyone who does not watch summits.
|
// everyone who does not watch summits.
|
||||||
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
|
group: 'Spot', label: t('clg2.c.sota'), colId: 'sota',
|
||||||
headerName: t('clg2.c.sota'), field: 'sota_ref' as any, width: 100, cellClass: 'font-mono',
|
headerName: t('clg2.c.sota'), width: 100, cellClass: 'font-mono',
|
||||||
|
valueGetter: (p: any) => (chaseSota() ? p.data?.sota_ref ?? '' : ''),
|
||||||
defaultVisible: false,
|
defaultVisible: false,
|
||||||
cellStyle: () => ({ color: 'var(--success)' }) as any,
|
cellStyle: () => ({ color: 'var(--success)' }) as any,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
||||||
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
||||||
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
||||||
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT,
|
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT, SetKenwoodPanelMode,
|
||||||
GetCATState,
|
GetCATState,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -14,7 +14,7 @@ import { MeterBar } from '@/components/MeterBar';
|
|||||||
import { WheelRange } from '@/components/WheelRange';
|
import { WheelRange } from '@/components/WheelRange';
|
||||||
|
|
||||||
type KenwoodState = {
|
type KenwoodState = {
|
||||||
available: boolean; model?: string; elecraft: boolean; mode?: string;
|
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
||||||
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
||||||
s_meter: number; s_meter_raw: number;
|
s_meter: number; s_meter_raw: number;
|
||||||
power_meter: number; swr: number; swr_raw: number;
|
power_meter: number; swr: number; swr_raw: number;
|
||||||
@@ -89,6 +89,16 @@ function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off:
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isDataMode: what the rig reports for MD6 varies — "DATA", or the configured
|
||||||
|
// digital default (FT8…) — so the DATA family is "not one of the native modes".
|
||||||
|
function isDataMode(m?: string): boolean {
|
||||||
|
switch ((m ?? '').toUpperCase()) {
|
||||||
|
case '': case 'CW': case 'USB': case 'LSB': case 'SSB': case 'FM': case 'AM': case 'RTTY':
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<KenwoodState>(ZERO);
|
const [st, setSt] = useState<KenwoodState>(ZERO);
|
||||||
@@ -149,6 +159,28 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
|
|||||||
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* Mode row. The two DATA buttons are why it exists: MD6 alone keeps
|
||||||
|
whatever submode the last session left, and a K3 "in DATA" with FSK D
|
||||||
|
still armed keys FT8 with no audio. DATA sends MD6+DT0 (DATA A, the
|
||||||
|
soundcard path), RTTY sends MD6+DT2 (FSK D). The active DATA button
|
||||||
|
follows the rig's own DT answer. */}
|
||||||
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
||||||
|
{([
|
||||||
|
['CW', 'CW', view.mode === 'CW'],
|
||||||
|
['USB', 'USB', view.mode === 'USB'],
|
||||||
|
['LSB', 'LSB', view.mode === 'LSB'],
|
||||||
|
['DATA', t('k3.modeData'), isDataMode(view.mode) && view.data_sub !== 'FSK D' && view.data_sub !== 'PSK D'],
|
||||||
|
['RTTY', t('k3.modeRtty'), (isDataMode(view.mode) && (view.data_sub === 'FSK D' || view.data_sub === 'PSK D')) || view.mode === 'RTTY'],
|
||||||
|
] as [string, string, boolean][]).map(([cmd, label, on]) => (
|
||||||
|
<button key={cmd} type="button" disabled={off}
|
||||||
|
title={cmd === 'DATA' ? t('k3.modeDataHint') : cmd === 'RTTY' ? t('k3.modeRttyHint') : label}
|
||||||
|
onClick={() => SetKenwoodPanelMode(cmd).catch((e: any) => setErr(String(e?.message ?? e)))}
|
||||||
|
className={cn('px-2.5 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
||||||
|
on ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
||||||
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
||||||
<SlidersHorizontal className="size-3.5" />
|
<SlidersHorizontal className="size-3.5" />
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ const UPLOAD_COLS: ColDef<UploadRow>[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
type Confirmation = {
|
type Confirmation = {
|
||||||
callsign: string; qso_date: string; band: string; mode: string; country: string;
|
callsign: string; station?: string; qso_date: string; band: string; mode: string; country: string;
|
||||||
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
new_dxcc: boolean; new_band: boolean; new_mode: boolean; new_slot: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -619,6 +619,10 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<thead className="sticky top-0 bg-card">
|
<thead className="sticky top-0 bg-card">
|
||||||
<tr className="text-left text-muted-foreground border-b border-border">
|
<tr className="text-left text-muted-foreground border-b border-border">
|
||||||
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thDateUtc')}</th><th className="py-1.5 px-2">{t('qslm.thCallsign')}</th>
|
||||||
|
{/* Which of the operator's callsigns the contact was made
|
||||||
|
under. Only worth a column when the list can hold more than
|
||||||
|
one — see "All my callsigns" on the download. */}
|
||||||
|
{shownConfs.some((c) => c.station) && <th className="py-1.5 px-2">{t('qslm.thStation')}</th>}
|
||||||
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thBand')}</th><th className="py-1.5 px-2">{t('qslm.thMode')}</th>
|
||||||
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
<th className="py-1.5 px-2">{t('qslm.thCountry')}</th><th className="py-1.5 px-2">{t('qslm.thNew')}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -628,6 +632,9 @@ export function QSLManagerPanel({ onEditQSO, actions, paperRequest }: {
|
|||||||
<tr key={i} className="border-b border-border/40">
|
<tr key={i} className="border-b border-border/40">
|
||||||
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
<td className="py-1 px-2 font-mono">{fmtDate(c.qso_date)}</td>
|
||||||
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
<td className="py-1 px-2 font-mono font-bold">{c.callsign}</td>
|
||||||
|
{shownConfs.some((x) => x.station) && (
|
||||||
|
<td className="py-1 px-2 font-mono text-muted-foreground">{c.station ?? ''}</td>
|
||||||
|
)}
|
||||||
<td className="py-1 px-2">{c.band}</td>
|
<td className="py-1 px-2">{c.band}</td>
|
||||||
<td className="py-1 px-2">{c.mode}</td>
|
<td className="py-1 px-2">{c.mode}</td>
|
||||||
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
<td className="py-1 px-2 text-muted-foreground">{c.country}</td>
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ import {
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { setUseMiles } from '@/lib/units';
|
import { setUseMiles } from '@/lib/units';
|
||||||
|
import { iaruRegion, setIaruRegion, type IaruRegion } from '@/lib/bandplan';
|
||||||
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
import { getDateFormat, setDateFormat, type DateFormat } from '@/lib/dateFormat';
|
||||||
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
import { useI18n, FlagGB, FlagFR, type Lang } from '@/lib/i18n';
|
||||||
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
import { useTheme, CONCRETE_THEMES, type ThemeChoice } from '@/lib/theme';
|
||||||
@@ -689,8 +690,7 @@ function TelemetryToggle() {
|
|||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
<Checkbox checked={on} disabled={!loaded}
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
onCheckedChange={(c) => { const v = !!c; setOn(v); SetTelemetryEnabled(v).catch(() => {}); }} />
|
||||||
{t('settings.telemetry')}
|
<span title={t('settings.telemetryHint')}>{t('settings.telemetry')}</span>
|
||||||
<span className="text-xs text-muted-foreground">({t('settings.telemetryHint')})</span>
|
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1749,6 +1749,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
const [lookupOnBlur, setLookupOnBlur] = useState(() => localStorage.getItem('opslog.lookupOnBlur') === '1');
|
||||||
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
const [groupDigital, setGroupDigital] = useState(() => localStorage.getItem('opslog.groupDigitalSlots') === '1');
|
||||||
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
const [milesUnit, setMilesUnit] = useState(() => localStorage.getItem('opslog.distanceMiles') === '1');
|
||||||
|
const [region, setRegion] = useState<IaruRegion>(() => iaruRegion());
|
||||||
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
const [clusterWorkedSameSlot, setClusterWorkedSameSlot] = useState(() => localStorage.getItem('opslog.clusterWorkedSameSlot') === '1');
|
||||||
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
// Declared HERE and not in ClusterPanel: that renderer is called as a plain
|
||||||
// function by the PANELS map, so it must stay hooks-free.
|
// function by the PANELS map, so it must stay hooks-free.
|
||||||
@@ -2067,6 +2068,8 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
try { await SaveGridScopeSettings(next); } catch { /* the panel keeps the choice either way */ }
|
try { await SaveGridScopeSettings(next); } catch { /* the panel keeps the choice either way */ }
|
||||||
};
|
};
|
||||||
const [chaseGrids, setChaseGrids] = useState(false);
|
const [chaseGrids, setChaseGrids] = useState(false);
|
||||||
|
const [chasePotaOn, setChasePotaOn] = useState(() => localStorage.getItem('opslog.chasePota') !== '0');
|
||||||
|
const [chaseSotaOn, setChaseSotaOn] = useState(() => localStorage.getItem('opslog.chaseSota') !== '0');
|
||||||
const [chaseNew, setChaseNew] = useState(false);
|
const [chaseNew, setChaseNew] = useState(false);
|
||||||
const [spotTTL, setSpotTTL] = useState(0);
|
const [spotTTL, setSpotTTL] = useState(0);
|
||||||
const [spotTTLText, setSpotTTLText] = useState('0');
|
const [spotTTLText, setSpotTTLText] = useState('0');
|
||||||
@@ -5313,11 +5316,6 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('clu.freeNodes')} <span className="font-mono">dxc.k0xm.net:7300</span>,{' '}
|
|
||||||
<span className="font-mono">dx.maritimecontestclub.net:7300</span>,{' '}
|
|
||||||
<span className="font-mono">w8avi.net:7300</span>.
|
|
||||||
</p>
|
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
<label className="flex items-start gap-2 text-sm cursor-pointer border-t border-border/60 pt-3">
|
||||||
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
<Checkbox checked={clusterWorkedSameSlot} className="mt-0.5"
|
||||||
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
onCheckedChange={(c) => { const v = !!c; setClusterWorkedSameSlot(v); writeUiPref('opslog.clusterWorkedSameSlot', v ? '1' : '0'); }} />
|
||||||
@@ -5379,11 +5377,24 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const n = parseInt(raw, 10);
|
const n = parseInt(raw, 10);
|
||||||
if (Number.isFinite(n) && n > 0) SetSpotMax(Math.min(10000, n)).catch(() => {});
|
if (Number.isFinite(n) && n > 0) SetSpotMax(Math.min(10000, n)).catch(() => {});
|
||||||
}} />
|
}} />
|
||||||
<span className="text-xs text-muted-foreground">{t('clu.spotMaxHint')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
|
{/* 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. */}
|
||||||
|
<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'); }} />
|
||||||
|
{t('clu.chasePota')}
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('clu.chaseSotaHint')}>
|
||||||
|
<Checkbox checked={chaseSotaOn}
|
||||||
|
onCheckedChange={(c) => { const v = !!c; setChaseSotaOn(v); writeUiPref('opslog.chaseSota', v ? '1' : '0'); }} />
|
||||||
|
{t('clu.chaseSota')}
|
||||||
|
</label>
|
||||||
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
<label className="flex items-start gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={chaseGrids} className="mt-0.5"
|
<Checkbox checked={chaseGrids} className="mt-0.5"
|
||||||
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
onCheckedChange={(c) => { setChaseGrids(!!c); SetChaseNewGrids(!!c).catch(() => {}); }} />
|
||||||
@@ -6777,7 +6788,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-72" title={t('gen.iaruHint')}><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
<SelectItem value="sqlite">{t('db.optSqlite')}</SelectItem>
|
||||||
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
<SelectItem value="mysql">{t('db.optMysql')}</SelectItem>
|
||||||
@@ -7147,7 +7158,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
const { lang, setLang, t } = useI18n();
|
const { lang, setLang, t } = useI18n();
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title={t('sec.general')} hint={t('gen.hint')} />
|
<SectionHeader title={t('sec.general')} />
|
||||||
<div className="space-y-3 max-w-3xl">
|
<div className="space-y-3 max-w-3xl">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label className="text-sm w-40">{t('settings.language')}</Label>
|
<Label className="text-sm w-40">{t('settings.language')}</Label>
|
||||||
@@ -7175,15 +7186,32 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
['us', t('gen.dateUS'), '07-30-2026 14:25']] as [DateFormat, string, string][]).map(([code, label, sample]) => (
|
||||||
<button key={code} type="button" title={sample}
|
<button key={code} type="button" title={sample}
|
||||||
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
onClick={() => { setDateFormat(code); setDateFmtSel(code); }}
|
||||||
className={cn('flex flex-col items-start px-3 py-1 text-sm font-medium border-l border-border first:border-l-0',
|
className={cn('px-3 py-1.5 text-sm font-medium border-l border-border first:border-l-0',
|
||||||
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
dateFmtSel === code ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
{label}
|
{label}
|
||||||
<span className="text-[10px] font-mono opacity-70">{sample}</span>
|
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
{/* The IARU region drives the band edges and mode segments the band
|
||||||
|
maps draw — Region 2's 40 m runs to 7300, Region 1's stops at
|
||||||
|
7200 — and nothing else: spots and logging are region-blind. */}
|
||||||
|
<Label className="text-sm w-40 shrink-0">{t('gen.iaruRegion')}</Label>
|
||||||
|
<Select value={String(region)} onValueChange={(v) => {
|
||||||
|
const r = (v === '2' ? 2 : v === '3' ? 3 : 1) as IaruRegion;
|
||||||
|
setRegion(r); setIaruRegion(r);
|
||||||
|
}}>
|
||||||
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">{t('gen.iaruR1')}</SelectItem>
|
||||||
|
<SelectItem value="2">{t('gen.iaruR2')}</SelectItem>
|
||||||
|
<SelectItem value="3">{t('gen.iaruR3')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ThemeSelector />
|
<ThemeSelector />
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -7205,11 +7233,10 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
{/* Super Check Partial / N+1 — downloads the community MASTER.SCP list and
|
||||||
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
shows a two-column callsign helper (partial matches + one-edit calls). */}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer" title={t('scp.settingsHint')}>
|
||||||
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
<Checkbox checked={scp.enabled} disabled={scpBusy} onCheckedChange={(c) => toggleScp(!!c)} />
|
||||||
{t('scp.enable')}
|
{t('scp.enable')}
|
||||||
</label>
|
</label>
|
||||||
<p className="text-[11px] text-muted-foreground">{t('scp.settingsHint')}</p>
|
|
||||||
{scp.enabled && (
|
{scp.enabled && (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
<Button variant="outline" size="sm" onClick={downloadScp} disabled={scpBusy}>
|
||||||
@@ -7237,18 +7264,18 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
<Checkbox checked={groupDigital} onCheckedChange={(c) => { const v = !!c; setGroupDigital(v); writeUiPref('opslog.groupDigitalSlots', v ? '1' : '0'); }} />
|
||||||
{t('gen.groupDigital')} <span className="text-xs text-muted-foreground">{t('gen.groupDigitalHint')}</span>
|
<span title={t('gen.groupDigitalHint')}>{t('gen.groupDigital')}</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
{/* Distances are computed in km everywhere and converted at display
|
{/* Distances are computed in km everywhere and converted at display
|
||||||
time — see lib/units. Changing this repaints the columns that
|
time — see lib/units. Changing this repaints the columns that
|
||||||
already carry a distance; nothing stored moves. */}
|
already carry a distance; nothing stored moves. */}
|
||||||
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
|
<Checkbox checked={milesUnit} onCheckedChange={(c) => { const v = !!c; setMilesUnit(v); setUseMiles(v); }} />
|
||||||
{t('gen.miles')} <span className="text-xs text-muted-foreground">{t('gen.milesHint')}</span>
|
{t('gen.miles')}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
<Checkbox checked={checkUpdates} onCheckedChange={(c) => { const v = !!c; setCheckUpdates(v); writeUiPref('opslog.checkUpdates', v ? '1' : '0'); }} />
|
||||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
{t('gen.checkUpdates')}
|
||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
|
|
||||||
@@ -7311,12 +7338,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
|
|||||||
}}
|
}}
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
/>
|
/>
|
||||||
<span>
|
<span title={t('gen.clubDesc')}>{t('gen.clubUse')}</span>
|
||||||
{t('gen.clubUse')}
|
|
||||||
<span className="block text-xs text-muted-foreground mt-0.5">
|
|
||||||
{t('gen.clubDesc')}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
<div className="flex items-center gap-3 pl-6">
|
<div className="flex items-center gap-3 pl-6">
|
||||||
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
<Button variant="outline" size="sm" className="h-8" disabled={clubBusy}
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// IARU band plans, by region.
|
||||||
|
//
|
||||||
|
// The band maps used to carry one hard-coded table — Region 1's — so a US or
|
||||||
|
// Australian operator saw 40 m stop at 7200 while stations sat spotted at 7250,
|
||||||
|
// and the SSB wash started 25 kHz early. The region is a station-level fact the
|
||||||
|
// operator states once (Settings → General); everything drawing a band edge or
|
||||||
|
// a mode segment reads it from here.
|
||||||
|
//
|
||||||
|
// The tables are deliberately coarse: a band map's wash is CONTEXT, not a
|
||||||
|
// regulatory chart. Only the segments that differ between regions in ways an
|
||||||
|
// operator notices are split out; national fine print (US licence classes,
|
||||||
|
// beacon slots) does not belong in a background tint.
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
|
||||||
|
export type IaruRegion = 1 | 2 | 3;
|
||||||
|
export const KEY_IARU_REGION = 'opslog.iaruRegion';
|
||||||
|
|
||||||
|
export function iaruRegion(): IaruRegion {
|
||||||
|
try {
|
||||||
|
const v = localStorage.getItem(KEY_IARU_REGION);
|
||||||
|
return v === '2' ? 2 : v === '3' ? 3 : 1;
|
||||||
|
} catch { return 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setIaruRegion(r: IaruRegion): void {
|
||||||
|
writeUiPref(KEY_IARU_REGION, String(r));
|
||||||
|
listeners.forEach((l) => l());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The band maps capture the plan inside their render, so a change must reach
|
||||||
|
// them the way the distance unit reaches the grids.
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
export function subscribeIaruRegion(fn: () => void): () => void {
|
||||||
|
listeners.add(fn);
|
||||||
|
return () => { listeners.delete(fn); };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SegMode = 'cw' | 'digi' | 'phone';
|
||||||
|
type Seg = [number, number, SegMode];
|
||||||
|
|
||||||
|
interface Plan {
|
||||||
|
ranges: Record<string, [number, number]>;
|
||||||
|
segments: Record<string, Seg[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Region 1 — Europe / Africa / Middle East / northern Asia. The baseline the
|
||||||
|
// other two override.
|
||||||
|
const R1: Plan = {
|
||||||
|
ranges: {
|
||||||
|
'160m': [1800, 2000], '80m': [3500, 3800], '60m': [5350, 5450],
|
||||||
|
'40m': [7000, 7200], '30m': [10100, 10150], '20m': [14000, 14350],
|
||||||
|
'17m': [18068, 18168], '15m': [21000, 21450], '12m': [24890, 24990],
|
||||||
|
'10m': [28000, 29700], '6m': [50000, 50500], '4m': [70000, 70500],
|
||||||
|
'2m': [144000, 146000], '70cm': [430000, 440000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
'160m': [[1800, 1838, 'cw'], [1838, 1840, 'digi'], [1840, 2000, 'phone']],
|
||||||
|
'80m': [[3500, 3580, 'cw'], [3580, 3600, 'digi'], [3600, 3800, 'phone']],
|
||||||
|
'60m': [[5350, 5450, 'phone']],
|
||||||
|
'40m': [[7000, 7040, 'cw'], [7040, 7100, 'digi'], [7100, 7200, 'phone']],
|
||||||
|
'30m': [[10100, 10130, 'cw'], [10130, 10150, 'digi']],
|
||||||
|
'20m': [[14000, 14070, 'cw'], [14070, 14100, 'digi'], [14100, 14350, 'phone']],
|
||||||
|
'17m': [[18068, 18095, 'cw'], [18095, 18110, 'digi'], [18110, 18168, 'phone']],
|
||||||
|
'15m': [[21000, 21070, 'cw'], [21070, 21150, 'digi'], [21150, 21450, 'phone']],
|
||||||
|
'12m': [[24890, 24915, 'cw'], [24915, 24940, 'digi'], [24940, 24990, 'phone']],
|
||||||
|
'10m': [[28000, 28070, 'cw'], [28070, 28300, 'digi'], [28300, 29700, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 50500, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region 2 — the Americas. 80 m runs to 4000 and 40 m to 7300, with phone from
|
||||||
|
// 7125 (the whole point of asking the region: a KP4 ragchew on 7250 must not
|
||||||
|
// hang past the top of the map).
|
||||||
|
const R2: Plan = {
|
||||||
|
ranges: {
|
||||||
|
...R1.ranges,
|
||||||
|
'80m': [3500, 4000], '40m': [7000, 7300],
|
||||||
|
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [420000, 450000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
...R1.segments,
|
||||||
|
'80m': [[3500, 3570, 'cw'], [3570, 3600, 'digi'], [3600, 4000, 'phone']],
|
||||||
|
'40m': [[7000, 7040, 'cw'], [7040, 7125, 'digi'], [7125, 7300, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region 3 — Asia-Pacific. 80 m to 3900; 40 m as Region 1's shape.
|
||||||
|
const R3: Plan = {
|
||||||
|
ranges: {
|
||||||
|
...R1.ranges,
|
||||||
|
'80m': [3500, 3900],
|
||||||
|
'6m': [50000, 54000], '2m': [144000, 148000], '70cm': [430000, 450000],
|
||||||
|
},
|
||||||
|
segments: {
|
||||||
|
...R1.segments,
|
||||||
|
'80m': [[3500, 3535, 'cw'], [3535, 3600, 'digi'], [3600, 3900, 'phone']],
|
||||||
|
'6m': [[50000, 50100, 'cw'], [50100, 54000, 'phone']],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function plan(): Plan {
|
||||||
|
const r = iaruRegion();
|
||||||
|
return r === 2 ? R2 : r === 3 ? R3 : R1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandRange(band: string): [number, number] | undefined {
|
||||||
|
return plan().ranges[band];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bandSegments(band: string): Seg[] {
|
||||||
|
return plan().segments[band] ?? [];
|
||||||
|
}
|
||||||
@@ -130,7 +130,13 @@ const en: Dict = {
|
|||||||
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
'icmp.scopeNoStream': 'This radio does not send its scope over CI-V — its own screen still works.',
|
||||||
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
|
'wk.catWarnTci': 'The CW keyer is set to the radio, but the CAT backend is "{backend}". Pick TCI in Settings → CAT, or another keying engine.',
|
||||||
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
'wk.tciHint': "Keying goes through the radio's own macro keyer, over the link already open — no WinKeyer and no second serial port. The radio can stop a message but cannot un-type one, so there is no type-ahead correction here.",
|
||||||
|
'k3.modeData': 'DATA', 'k3.modeRtty': 'DATA RTTY',
|
||||||
|
'k3.modeDataHint': 'DATA A (MD6 + DT0) — the soundcard submode FT8/FT4/PSK modulate through',
|
||||||
|
'k3.modeRttyHint': 'FSK D (MD6 + DT2) — direct-keyed RTTY',
|
||||||
|
'gen.iaruRegion': 'IARU region', 'gen.iaruHint': '(band plan drawn on the band maps)',
|
||||||
|
'gen.iaruR1': 'Region 1 — Europe, Africa, Middle East', 'gen.iaruR2': 'Region 2 — Americas', 'gen.iaruR3': 'Region 3 — Asia-Pacific',
|
||||||
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
'gen.miles': 'Distances in miles', 'gen.milesHint': '(instead of kilometres)',
|
||||||
|
'qslm.thStation': 'Station',
|
||||||
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
'qslm.qrzTitle': 'Open this callsign on QRZ.com',
|
||||||
'qslm.lotwDetail': 'QSL details',
|
'qslm.lotwDetail': 'QSL details',
|
||||||
'qslm.lotwDetailTitle': 'Ask LoTW for the QSL date and the station details (grid, state, county) as well as the confirmation. LoTW takes about ten times longer to build that report — two minutes against twenty on the same account — and marking a confirmation needs none of it. Forced on when adding the QSOs not found, which have no other source for those fields.',
|
'qslm.lotwDetailTitle': 'Ask LoTW for the QSL date and the station details (grid, state, county) as well as the confirmation. LoTW takes about ten times longer to build that report — two minutes against twenty on the same account — and marking a confirmation needs none of it. Forced on when adding the QSOs not found, which have no other source for those fields.',
|
||||||
@@ -327,10 +333,10 @@ const en: Dict = {
|
|||||||
'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.',
|
'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',
|
'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.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': 'The list is a ring buffer: past this, the oldest spot drops out. It is usually this, not the lifetime, that decides — a thousand spots arrive in a few minutes on a busy evening.', 'clu.spotTtlHint': 'minutes — spots older than this are removed from the list and the band maps. 0 keeps them.', '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',
|
'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': 'I 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': 'I 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',
|
||||||
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
|
'clu.macros': 'Command buttons', 'clu.macrosHint': 'A named button beside the cluster command box. Leave the command empty and the button is not shown.',
|
||||||
'clu.macroLabel': 'Button', 'clu.macroCmd': 'Command',
|
'clu.macroLabel': 'Button', 'clu.macroCmd': 'Command',
|
||||||
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere. Combines with digital-mode grouping (Settings → General): with it on, a call worked on 20m FT8 also counts as worked for a 20m FT4 spot; with it off, FT8 and FT4 are separate slots.',
|
'clu.workedSameSlotHint': '— a spot shows "worked" only if you worked that call on the SAME band and mode, not just anywhere.',
|
||||||
// Backup panel
|
// Backup panel
|
||||||
|
|
||||||
|
|
||||||
@@ -630,7 +636,13 @@ const fr: Dict = {
|
|||||||
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
'icmp.scopeNoStream': "Cette radio n'envoie pas son scope en CI-V — son propre écran fonctionne toujours.",
|
||||||
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
|
'wk.catWarnTci': "Le manipulateur CW est réglé sur la radio, mais le backend CAT est « {backend} ». Choisissez TCI dans Réglages → CAT, ou un autre moteur de manipulation.",
|
||||||
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
'wk.tciHint': "La manipulation passe par le keyer à macros de la radio, sur la liaison déjà ouverte — ni WinKeyer ni second port série. La radio sait interrompre un message mais pas en effacer la fin, donc pas de correction en frappe anticipée ici.",
|
||||||
|
'k3.modeData': 'DATA', 'k3.modeRtty': 'DATA RTTY',
|
||||||
|
'k3.modeDataHint': 'DATA A (MD6 + DT0) — le sous-mode carte son par lequel FT8/FT4/PSK modulent',
|
||||||
|
'k3.modeRttyHint': 'FSK D (MD6 + DT2) — RTTY en manipulation directe',
|
||||||
|
'gen.iaruRegion': 'Région IARU', 'gen.iaruHint': '(plan de bande dessiné sur les cartes de bande)',
|
||||||
|
'gen.iaruR1': 'Région 1 — Europe, Afrique, Moyen-Orient', 'gen.iaruR2': 'Région 2 — Amériques', 'gen.iaruR3': 'Région 3 — Asie-Pacifique',
|
||||||
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
'gen.miles': 'Distances en miles', 'gen.milesHint': '(au lieu des kilomètres)',
|
||||||
|
'qslm.thStation': 'Station',
|
||||||
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
'qslm.qrzTitle': 'Ouvrir cet indicatif sur QRZ.com',
|
||||||
'qslm.lotwDetail': 'Détails QSL',
|
'qslm.lotwDetail': 'Détails QSL',
|
||||||
'qslm.lotwDetailTitle': "Demander à LoTW la date du QSL et les détails de la station (locator, état, comté) en plus de la confirmation. LoTW met environ dix fois plus longtemps à construire ce rapport — deux minutes contre vingt sur le même compte — et marquer une confirmation n'en a pas besoin. Forcé quand on ajoute les QSO absents, qui n'ont pas d'autre source pour ces champs.",
|
'qslm.lotwDetailTitle': "Demander à LoTW la date du QSL et les détails de la station (locator, état, comté) en plus de la confirmation. LoTW met environ dix fois plus longtemps à construire ce rapport — deux minutes contre vingt sur le même compte — et marquer une confirmation n'en a pas besoin. Forcé quand on ajoute les QSO absents, qui n'ont pas d'autre source pour ces champs.",
|
||||||
@@ -823,8 +835,8 @@ const fr: Dict = {
|
|||||||
'bo.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il 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 n’a 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.nearKm': 'Compter les récepteurs à moins de', 'bo.nearKmHint': 'Un report ne prouve TON chemin que s’il 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 n’a 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',
|
'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',
|
||||||
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.',
|
'clu.macros': 'Boutons de commande', 'clu.macrosHint': 'Un bouton nommé à côté du champ de commande du cluster. Laisse la commande vide et le bouton n’est pas affiché.',
|
||||||
'clu.macroLabel': 'Bouton', 'clu.macroCmd': 'Commande', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotMax': 'Nombre de spots conservés', 'clu.spotMaxHint': "La liste est un tampon circulaire : au-delà, le plus ancien sort. C'est souvent lui, et non la durée de vie, qui décide — mille spots arrivent en quelques minutes un soir chargé.", 'clu.spotTtlHint': 'minutes — les spots plus vieux que ça sont retirés de la liste et des bandmaps. 0 les conserve.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture',
|
'clu.macroLabel': 'Bouton', 'clu.macroCmd': 'Commande', 'clu.spotTtl': 'Durée de vie des spots', 'clu.spotTtlNever': 'Garder', 'clu.spotMax': 'Nombre de spots conservés', 'clu.spotMaxHint': '', 'clu.spotTtlHint': 'minutes — 0 les conserve.', 'clu.chasePota': 'Je chasse le POTA', 'clu.chasePotaHint': "Décoché : plus de badge ni de filtre NOUVEAU POTA, et la colonne POTA reste vide — un spot nouvelle bande + nouveau POTA affiche seulement NOUVELLE BANDE.", 'clu.chaseSota': 'Je chasse le SOTA', 'clu.chaseSotaHint': 'Décoché : la colonne SOTA reste vide.', 'clu.chaseGrids': 'Chasser les nouveaux locators', 'clu.chaseGridsHint': '(apprend les locators depuis TES propres décodages WSJT-X ET depuis PSK Reporter, et les garde dans leur propre base pour que le cluster les affiche dès la première seconde)', 'clu.chaseGridsStat': '{n} locators connus — {p} en attente d’écriture',
|
||||||
'clu.workedSameSlotHint': '— un spot n\'affiche « contacté » que si vous avez contacté cet indicatif sur la MÊME bande et le MÊME mode, pas juste n\'importe où. Se combine avec le groupage des modes numériques (Réglages → Général) : activé, un indicatif contacté en 20m FT8 compte aussi comme contacté pour un spot 20m FT4 ; désactivé, FT8 et FT4 sont des slots distincts.',
|
'clu.workedSameSlotHint': "— un spot n'est « contacté » que si cet indicatif a été fait sur la MÊME bande et le même mode, pas n'importe où.",
|
||||||
|
|
||||||
|
|
||||||
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
'bk.auto': 'Sauvegarde auto à la fermeture d\'OpsLog (max 1×/jour)', 'bk.folder': 'Dossier de sauvegarde', 'bk.folderPh': 'vide = dossier par défaut', 'bk.browse': 'Parcourir…',
|
||||||
|
|||||||
@@ -11,7 +11,25 @@
|
|||||||
//
|
//
|
||||||
// They compose deliberately: mute what is done, light up what is not.
|
// They compose deliberately: mute what is done, light up what is not.
|
||||||
|
|
||||||
export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean };
|
export type SpotDisplayOptions = {
|
||||||
|
muteWorked: boolean; slotHighlight: boolean;
|
||||||
|
// Chase switches (Settings → DX Cluster), ON by default. An operator who does
|
||||||
|
// not chase parks does not want NEW POTA shouting from every activator spot:
|
||||||
|
// off, the marker is withdrawn at the display layer — a "new band + new POTA"
|
||||||
|
// spot simply reads NEW BAND — and the reference column goes quiet. The facts
|
||||||
|
// keep being computed; only the telling stops, so ticking the box back on
|
||||||
|
// needs no rescan.
|
||||||
|
chasePota: boolean; chaseSota: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// chasePota/chaseSota read the switches directly — for the places that show a
|
||||||
|
// REFERENCE rather than a status (the POTA and SOTA columns).
|
||||||
|
export function chasePota(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chasePota') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
export function chaseSota(): boolean {
|
||||||
|
try { return localStorage.getItem('opslog.chaseSota') !== '0'; } catch { return true; }
|
||||||
|
}
|
||||||
|
|
||||||
// Both options are withdrawn from the filter panel for now. The machinery below
|
// Both options are withdrawn from the filter panel for now. The machinery below
|
||||||
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
// is deliberately kept whole — it is correct and hard-won — so putting the two
|
||||||
@@ -23,14 +41,19 @@ export type SpotDisplayOptions = { muteWorked: boolean; slotHighlight: boolean }
|
|||||||
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
export const SPOT_DISPLAY_OPTIONS_EXPOSED = false;
|
||||||
|
|
||||||
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
export function readSpotDisplayOptions(): SpotDisplayOptions {
|
||||||
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) return { muteWorked: false, slotHighlight: false };
|
// The EXPOSED flag only withdraws the two original switches; the chase
|
||||||
|
// switches are live regardless.
|
||||||
|
if (!SPOT_DISPLAY_OPTIONS_EXPOSED) {
|
||||||
|
return { muteWorked: false, slotHighlight: false, chasePota: chasePota(), chaseSota: chaseSota() };
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return {
|
return {
|
||||||
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
muteWorked: localStorage.getItem('opslog.clusterMuteWorked') === '1',
|
||||||
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
slotHighlight: localStorage.getItem('opslog.clusterSlotHighlight') === '1',
|
||||||
|
chasePota: chasePota(), chaseSota: chaseSota(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return { muteWorked: false, slotHighlight: false };
|
return { muteWorked: false, slotHighlight: false, chasePota: true, chaseSota: true };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,6 +95,9 @@ export function applySpotDisplay<T extends Entry>(s: T, o: SpotDisplayOptions):
|
|||||||
if (o.muteWorked) {
|
if (o.muteWorked) {
|
||||||
e = { ...e, worked_call: false } as NonNullable<T>;
|
e = { ...e, worked_call: false } as NonNullable<T>;
|
||||||
}
|
}
|
||||||
|
if (!o.chasePota && e.new_pota) {
|
||||||
|
e = { ...e, new_pota: false } as NonNullable<T>;
|
||||||
|
}
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
'opslog.distanceMiles', // distances shown in statute miles rather than km
|
'opslog.distanceMiles', // distances shown in statute miles rather than km
|
||||||
|
'opslog.iaruRegion', // IARU region (1/2/3) — band edges and segments on the band maps
|
||||||
|
'opslog.chasePota', // show POTA references and the NEW POTA marker on spots
|
||||||
|
'opslog.chaseSota', // show SOTA references on spots
|
||||||
'opslog.activeTab', // last selected tab
|
'opslog.activeTab', // last selected tab
|
||||||
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
'opslog.mainSplit', // Main tab: width share of the left pane (percent) — legacy, read once to seed mainShares
|
||||||
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
'opslog.mainShares', // Main tab: column shares per column count, as {2:[..],3:[..],4:[..]}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Single source of truth for the app version shown in the UI (header + About).
|
// 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).
|
// Bump this on a release (the release script updates it alongside telemetry.go).
|
||||||
export const APP_VERSION = '0.26.22';
|
export const APP_VERSION = '0.26.23';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+2
@@ -1145,6 +1145,8 @@ export function SetKenwoodNB(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetKenwoodNR(arg1:boolean):Promise<void>;
|
export function SetKenwoodNR(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetKenwoodPanelMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodPower(arg1:number):Promise<void>;
|
export function SetKenwoodPower(arg1:number):Promise<void>;
|
||||||
|
|
||||||
export function SetKenwoodPreamp(arg1:boolean):Promise<void>;
|
export function SetKenwoodPreamp(arg1:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -2230,6 +2230,10 @@ export function SetKenwoodNR(arg1) {
|
|||||||
return window['go']['main']['App']['SetKenwoodNR'](arg1);
|
return window['go']['main']['App']['SetKenwoodNR'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetKenwoodPanelMode(arg1) {
|
||||||
|
return window['go']['main']['App']['SetKenwoodPanelMode'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetKenwoodPower(arg1) {
|
export function SetKenwoodPower(arg1) {
|
||||||
return window['go']['main']['App']['SetKenwoodPower'](arg1);
|
return window['go']['main']['App']['SetKenwoodPower'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1072,6 +1072,7 @@ export namespace cat {
|
|||||||
model?: string;
|
model?: string;
|
||||||
elecraft: boolean;
|
elecraft: boolean;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
|
data_sub?: string;
|
||||||
transmitting: boolean;
|
transmitting: boolean;
|
||||||
split: boolean;
|
split: boolean;
|
||||||
split_tx_hz: number;
|
split_tx_hz: number;
|
||||||
@@ -1108,6 +1109,7 @@ export namespace cat {
|
|||||||
this.model = source["model"];
|
this.model = source["model"];
|
||||||
this.elecraft = source["elecraft"];
|
this.elecraft = source["elecraft"];
|
||||||
this.mode = source["mode"];
|
this.mode = source["mode"];
|
||||||
|
this.data_sub = source["data_sub"];
|
||||||
this.transmitting = source["transmitting"];
|
this.transmitting = source["transmitting"];
|
||||||
this.split = source["split"];
|
this.split = source["split"];
|
||||||
this.split_tx_hz = source["split_tx_hz"];
|
this.split_tx_hz = source["split_tx_hz"];
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ type KenwoodTXState struct {
|
|||||||
Model string `json:"model,omitempty"`
|
Model string `json:"model,omitempty"`
|
||||||
Elecraft bool `json:"elecraft"` // a K3/K4 rather than a Kenwood
|
Elecraft bool `json:"elecraft"` // a K3/K4 rather than a Kenwood
|
||||||
Mode string `json:"mode,omitempty"`
|
Mode string `json:"mode,omitempty"`
|
||||||
|
// DataSub is the K3/K4 DATA submode while in DATA mode (DT): "DATA A",
|
||||||
|
// "AFSK A", "FSK D" or "PSK D". Empty on a Kenwood, and outside DATA. The
|
||||||
|
// panel's two DATA buttons need it to show WHICH data mode the rig is in —
|
||||||
|
// FT8 wants DATA A, RTTY wants FSK D, and MD6 alone cannot say which.
|
||||||
|
DataSub string `json:"data_sub,omitempty"`
|
||||||
|
|
||||||
Transmitting bool `json:"transmitting"`
|
Transmitting bool `json:"transmitting"`
|
||||||
Split bool `json:"split"`
|
Split bool `json:"split"`
|
||||||
@@ -111,6 +116,7 @@ type KenwoodPanelController interface {
|
|||||||
SetKenwoodTX(bool) error
|
SetKenwoodTX(bool) error
|
||||||
TuneKenwoodATU() error
|
TuneKenwoodATU() error
|
||||||
ToggleKenwoodATU() error
|
ToggleKenwoodATU() error
|
||||||
|
SetKenwoodPanelMode(string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// kenwoodPanelSlowBeat is how many polls pass between full re-reads of the
|
// kenwoodPanelSlowBeat is how many polls pass between full re-reads of the
|
||||||
@@ -212,6 +218,14 @@ func (k *Kenwood) readPanelSettings() {
|
|||||||
if v, ok := k.askNum("NR;", "NR", 1); ok {
|
if v, ok := k.askNum("NR;", "NR", 1); ok {
|
||||||
k.panel.NR = v != 0
|
k.panel.NR = v != 0
|
||||||
}
|
}
|
||||||
|
// The DATA submode, Elecraft only (a plain Kenwood has no DT and would "?;"
|
||||||
|
// it). Read every settings beat: the operator changes it from the rig's own
|
||||||
|
// front panel mid-session, and the two DATA buttons must follow.
|
||||||
|
if k.elecraft {
|
||||||
|
if v, ok := k.askNum("DT;", "DT", 1); ok {
|
||||||
|
k.panel.DataSub = kenwoodDataSubName(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
if v, ok := k.askNum("GT;", "GT", 3); ok {
|
if v, ok := k.askNum("GT;", "GT", 3); ok {
|
||||||
k.panel.AGC = kenwoodAGCName(v)
|
k.panel.AGC = kenwoodAGCName(v)
|
||||||
}
|
}
|
||||||
@@ -650,3 +664,58 @@ func (k *Kenwood) askNum(cmd, prefix string, digits int) (int, bool) {
|
|||||||
}
|
}
|
||||||
return n, true
|
return n, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// kenwoodDataSubName decodes DT (K3/K4 programmer's reference).
|
||||||
|
func kenwoodDataSubName(v int) string {
|
||||||
|
switch v {
|
||||||
|
case 0:
|
||||||
|
return "DATA A"
|
||||||
|
case 1:
|
||||||
|
return "AFSK A"
|
||||||
|
case 2:
|
||||||
|
return "FSK D"
|
||||||
|
case 3:
|
||||||
|
return "PSK D"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetKenwoodPanelMode is the panel's mode row: CW / USB / LSB / DATA / RTTY.
|
||||||
|
//
|
||||||
|
// It exists because SetMode is the LOGGER's path — it maps an ADIF mode and
|
||||||
|
// honours the data-mode preference — while a panel button is the operator
|
||||||
|
// saying exactly what the rig should do. The two DATA cases are the point:
|
||||||
|
// "DATA" is MD6 + DT0 (DATA A, the soundcard submode FT8/FT4 modulate through)
|
||||||
|
// and "RTTY" is MD6 + DT2 (FSK D, the K3's direct-keyed RTTY). MD6 alone keeps
|
||||||
|
// whatever submode a prior session left, which is how a K3 "in DATA" transmits
|
||||||
|
// FT8 with no audio — the report behind this row.
|
||||||
|
func (k *Kenwood) SetKenwoodPanelMode(mode string) error {
|
||||||
|
k.mu.Lock()
|
||||||
|
defer k.mu.Unlock()
|
||||||
|
if k.port == nil {
|
||||||
|
return fmt.Errorf("kenwood: not connected")
|
||||||
|
}
|
||||||
|
var cmds []string
|
||||||
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||||
|
case "CW":
|
||||||
|
cmds = []string{"MD3;"}
|
||||||
|
case "USB":
|
||||||
|
cmds = []string{"MD2;"}
|
||||||
|
case "LSB":
|
||||||
|
cmds = []string{"MD1;"}
|
||||||
|
case "DATA":
|
||||||
|
cmds = []string{"MD6;", "DT0;"}
|
||||||
|
case "RTTY":
|
||||||
|
cmds = []string{"MD6;", "DT2;"}
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("kenwood panel: unknown mode %q", mode)
|
||||||
|
}
|
||||||
|
for _, c := range cmds {
|
||||||
|
if err := k.write(c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Re-read the settings on the next poll so Mode and DataSub confirm at once.
|
||||||
|
k.panelCycle = kenwoodPanelSlowBeat
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
+26
-2
@@ -165,8 +165,7 @@ func (t *TCI) Connect() error {
|
|||||||
if t.spotsEnabled {
|
if t.spotsEnabled {
|
||||||
debugLog.Printf("TCI: panorama spots are ON — spots will be sent to the radio")
|
debugLog.Printf("TCI: panorama spots are ON — spots will be sent to the radio")
|
||||||
}
|
}
|
||||||
_ = t.send("rx_sensors_enable:true,200;")
|
t.subscribeSensors("connect")
|
||||||
_ = t.send("tx_sensors_enable:true,200;")
|
|
||||||
if t.spotsEnabled {
|
if t.spotsEnabled {
|
||||||
// Forget what we thought was on the panorama at the same moment the radio
|
// Forget what we thought was on the panorama at the same moment the radio
|
||||||
// is told to drop it. Kept, the memory would suppress the next spot for
|
// is told to drop it. Kept, the memory would suppress the next spot for
|
||||||
@@ -461,6 +460,24 @@ func (t *TCI) SetTXAudioSource(src string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// send writes a command to the WebSocket (one writer at a time).
|
// send writes a command to the WebSocket (one writer at a time).
|
||||||
|
// subscribeSensors asks the radio to push its meters. Nothing measures anything
|
||||||
|
// until this goes out — the S-meter, the transmit power and the SWR are all
|
||||||
|
// subscription-only (TCI §4.4) — and it is sent at connect AND again at every
|
||||||
|
// "ready", because a subscription sent during the server's initial dump can be
|
||||||
|
// dropped. 200 ms is the rate the protocol's own examples use.
|
||||||
|
func (t *TCI) subscribeSensors(when string) {
|
||||||
|
// Both cases, deliberately. Every other command this backend sends works in
|
||||||
|
// lower case, but the meters stayed silent on a real SunSDR through two
|
||||||
|
// rounds of fixes — and the protocol document's own examples are upper case
|
||||||
|
// (TX_SENSORS_ENABLE:true,200;). A server that is case-insensitive ignores
|
||||||
|
// the duplicate; one that is not finally hears the subscription.
|
||||||
|
e1 := t.send("rx_sensors_enable:true,200;")
|
||||||
|
e2 := t.send("tx_sensors_enable:true,200;")
|
||||||
|
_ = t.send("RX_SENSORS_ENABLE:true,200;")
|
||||||
|
_ = t.send("TX_SENSORS_ENABLE:true,200;")
|
||||||
|
debugLog.Printf("TCI: sensor subscription sent (%s, both cases): rx=%v tx=%v", when, e1, e2)
|
||||||
|
}
|
||||||
|
|
||||||
func (t *TCI) send(cmd string) error {
|
func (t *TCI) send(cmd string) error {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
c := t.conn
|
c := t.conn
|
||||||
@@ -567,6 +584,13 @@ func (t *TCI) handle(msg string) {
|
|||||||
}
|
}
|
||||||
case "ready", "start":
|
case "ready", "start":
|
||||||
t.ready = true
|
t.ready = true
|
||||||
|
// (Re)subscribe to the meters HERE, not only at connect. ExpertSDR3
|
||||||
|
// dumps its whole state and then says "ready"; a unidirectional control
|
||||||
|
// command sent while that dump is still in flight can be ignored, and
|
||||||
|
// the report from a real SunSDR — transmit meters still empty after the
|
||||||
|
// connect-time subscription — has exactly that shape. From a goroutine:
|
||||||
|
// send takes t.mu, which this handler holds.
|
||||||
|
go t.subscribeSensors("ready")
|
||||||
case "stop":
|
case "stop":
|
||||||
t.ready = false
|
t.ready = false
|
||||||
case "vfo":
|
case "vfo":
|
||||||
|
|||||||
@@ -113,7 +113,8 @@ func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
|
|||||||
// it. What the radio actually announces after the command settles whether
|
// it. What the radio actually announces after the command settles whether
|
||||||
// this is our reading or its doing, and no amount of reasoning will.
|
// this is our reading or its doing, and no amount of reasoning will.
|
||||||
switch name {
|
switch name {
|
||||||
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune":
|
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune",
|
||||||
|
"tx_sensors", "rx_sensors", "rx_channel_sensors":
|
||||||
// Logged on arrival so an ANSWER can be told from a SILENCE: the log
|
// Logged on arrival so an ANSWER can be told from a SILENCE: the log
|
||||||
// showed the transmit meters being asked for and nothing coming back,
|
// showed the transmit meters being asked for and nothing coming back,
|
||||||
// which on its own proves nothing — a reply that arrived and failed to
|
// which on its own proves nothing — a reply that arrived and failed to
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.26.22"
|
appVersion = "0.26.23"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user