Compare commits

...
3 Commits
Author SHA1 Message Date
rouggy ef628e066a fix(bandmap): follow the cluster's filters
The band map received the raw spot stream filtered by band alone, so none of
the cluster's filters reached it: switching on "LoTW users only" changed the
list and left the panadapter showing everyone. The same was true of hide
worked, the spotter continent, and the status and mode chips.

The predicate is extracted and shared rather than copied — two views of one
spot stream disagreeing about the same spot is the fault lib/spotDisplay
already exists to prevent, and a second copy would have drifted the first time
a filter was added.

The BAND filter is deliberately excluded from the shared part: a band map's
band is its filter, and applying the cluster's would empty every map but one.
2026-08-13 08:26:00 +02:00
rouggy 4ffdfc2548 feat(appearance): left stripe by default, with fill and strength as choices
The first version filled the whole row at 24%, and in a real log that meant
every row was painted: nearly every contact has SOME QSL state, so colour was
present everywhere and stopped being information — a striped background with
the data behind it.

The default is now a 3px stripe down the left edge. Same signal, nothing lost
to read it. A filled row is still offered, with a strength slider, for
operators who want the block — and the rule cards in Settings preview whichever
is chosen, so the decision is made by looking rather than by imagining.

Drawn as an inset shadow rather than a border: a border would shift the cells
three pixels on coloured rows only, and the columns would stop lining up.

Style and strength are normalised rather than rejected — a value out of range
is a slider that got away, not a reason to reset the operator's colours.
2026-08-13 01:42:08 +02:00
rouggy 83e7727aab chore: open 0.24.9 2026-08-13 01:26:47 +02:00
8 changed files with 159 additions and 23 deletions
+26 -3
View File
@@ -23,8 +23,18 @@ type RowColorRule struct {
// RowColorSettings is the whole appearance block. // RowColorSettings is the whole appearance block.
type RowColorSettings struct { type RowColorSettings struct {
Enabled bool `json:"enabled"` Enabled bool `json:"enabled"`
Rules []RowColorRule `json:"rules"` // Style is how the colour is shown: "bar" paints a stripe down the left edge,
// "tint" washes the row, "both" does each.
//
// A bar is the default because a filled row is a poor signal in a log where
// nearly every contact has SOME QSL state: colour that is always present
// stops being information and becomes a striped background, with the data
// behind it. The stripe says the same thing and costs nothing to read.
Style string `json:"style"`
// Intensity is the tint strength in percent. Only used by "tint"/"both".
Intensity int `json:"intensity"`
Rules []RowColorRule `json:"rules"`
} }
// The rule ids, in priority order. The frontend matches on these and holds the // The rule ids, in priority order. The frontend matches on these and holds the
@@ -56,7 +66,20 @@ func normRowColors(s RowColorSettings) RowColorSettings {
for _, r := range s.Rules { for _, r := range s.Rules {
byID[r.ID] = r byID[r.ID] = r
} }
out := RowColorSettings{Enabled: s.Enabled} out := RowColorSettings{Enabled: s.Enabled, Style: s.Style, Intensity: s.Intensity}
switch out.Style {
case "bar", "tint", "both":
default:
out.Style = "bar"
}
// Clamped rather than rejected: the value only shapes a colour-mix, and a
// number outside the range is a slider that got away, not a fault worth
// resetting the operator's whole choice for.
if out.Intensity < 5 {
out.Intensity = 12
} else if out.Intensity > 45 {
out.Intensity = 45
}
for _, id := range rowColorOrder { for _, id := range rowColorOrder {
r := byID[id] r := byID[id]
r.ID = id r.ID = id
+26
View File
@@ -49,3 +49,29 @@ func TestRowColorsKeepPriorityOrder(t *testing.T) {
t.Errorf("confirmed_lotw lost its colour: %q", got.Rules[0].Color) t.Errorf("confirmed_lotw lost its colour: %q", got.Rules[0].Color)
} }
} }
// Style and strength are normalised, not rejected: they only shape a colour, and
// a value that got away is a slider, not a fault worth resetting the operator's
// whole choice for.
func TestRowColorsNormaliseStyleAndIntensity(t *testing.T) {
for _, tc := range []struct {
inStyle string
inPct int
wantStyle string
wantPct int
}{
{"bar", 12, "bar", 12},
{"tint", 30, "tint", 30},
{"both", 45, "both", 45},
{"", 0, "bar", 12}, // never configured
{"wallpaper", 12, "bar", 12}, // not a style we draw
{"tint", 900, "tint", 45}, // clamped, not reset
{"tint", -5, "tint", 12},
} {
got := normRowColors(RowColorSettings{Style: tc.inStyle, Intensity: tc.inPct})
if got.Style != tc.wantStyle || got.Intensity != tc.wantPct {
t.Errorf("(%q,%d) -> (%q,%d), want (%q,%d)",
tc.inStyle, tc.inPct, got.Style, got.Intensity, tc.wantStyle, tc.wantPct)
}
}
}
+12
View File
@@ -1,4 +1,16 @@
[ [
{
"version": "0.24.9",
"date": "",
"en": [
"Appearance: row colouring now defaults to a left stripe, with a filled row and its strength offered as choices.",
"The band map now follows the cluster filters — LoTW only, spotter continent, hide worked, status and mode chips."
],
"fr": [
"Apparence : la coloration des lignes se fait par défaut sur une barre à gauche, la ligne remplie et son intensité restant proposées.",
"La band map suit désormais les filtres du cluster — LoTW seulement, continent du spotter, masquer les contactés, statuts et modes."
]
},
{ {
"version": "0.24.8", "version": "0.24.8",
"date": "", "date": "",
+24 -6
View File
@@ -4717,12 +4717,21 @@ export default function App() {
// Cluster spots after every active filter (band / mode / status / search / // Cluster spots after every active filter (band / mode / status / search /
// hide-worked / group). Shared by the Cluster tab and the Main-view cluster // hide-worked / group). Shared by the Cluster tab and the Main-view cluster
// pane so both show exactly the same list. // pane so both show exactly the same list.
const clusterRenderedRows = useMemo(() => { // Everything the operator has said about WHICH STATIONS they care about —
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands; // LoTW only, the spotter's continent, hide worked, the status and mode chips,
// the search box, the source node.
//
// Shared with the band map, which used to receive the raw stream filtered by
// band alone: switching on "LoTW users only" changed the cluster list and left
// the panadapter showing everyone. Two views of one spot stream disagreeing
// about the same spot is the fault lib/spotDisplay already exists to prevent.
//
// The BAND filter is deliberately not in here: a band map's band IS its
// filter, and applying the cluster's would empty every map but one.
const spotPassesStationFilters = useCallback((s: ClusterSpot): boolean => {
const search = clusterSearch.trim().toUpperCase(); const search = clusterSearch.trim().toUpperCase();
const list = spots.filter((s) => { {
if (clusterFilterSource && s.source_id !== clusterFilterSource) return false; if (clusterFilterSource && s.source_id !== clusterFilterSource) return false;
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
if (search && !s.dx_call.includes(search)) return false; if (search && !s.dx_call.includes(search)) return false;
if (clusterLockMode) { if (clusterLockMode) {
const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz); const spotMode = inferSpotMode(s.comment ?? '', s.freq_hz);
@@ -4774,6 +4783,15 @@ export default function App() {
if (e.worked_call || e.status === 'worked') return false; if (e.worked_call || e.status === 'worked') return false;
} }
return true; return true;
}
}, [clusterFilterSource, clusterSearch, clusterLockMode, mode, clusterModeFilter,
clusterStatusFilter, spotStatus, clusterLotwOnly, clusterSpotterConts, clusterHideWorked]);
const clusterRenderedRows = useMemo(() => {
const bandsActive = clusterLockBand ? new Set([band]) : clusterBands;
const list = spots.filter((s) => {
if (bandsActive.size > 0 && !bandsActive.has(s.band ?? '')) return false;
return spotPassesStationFilters(s);
}); });
let rendered = list as (ClusterSpot & { repeats?: number })[]; let rendered = list as (ClusterSpot & { repeats?: number })[];
if (clusterGroup) { if (clusterGroup) {
@@ -6775,7 +6793,7 @@ export default function App() {
</div> </div>
<BandMap <BandMap
band={b} band={b}
spots={spots.filter((s) => s.band === b)} spots={spots.filter((s) => s.band === b && spotPassesStationFilters(s))}
spotStatus={spotStatus} spotStatus={spotStatus}
currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} currentFreqHz={band === b && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
@@ -6811,7 +6829,7 @@ export default function App() {
side={bandMapSide} side={bandMapSide}
onToggleSide={toggleBandMapSide} onToggleSide={toggleBandMapSide}
band={band} band={band}
spots={spots.filter((s) => s.band === band)} spots={spots.filter((s) => s.band === band && spotPassesStationFilters(s))}
spotStatus={spotStatus} spotStatus={spotStatus}
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0} currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
onSpotClick={handleSpotClick} onSpotClick={handleSpotClick}
+35 -2
View File
@@ -42,6 +42,17 @@ export function AppearancePanel() {
save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) }); save({ ...cfg, rules: cfg.rules.map((r) => (r.id === id ? { ...r, ...patch } : r)) });
}; };
// The rule card shows the row exactly as the grid will draw it, so the choice
// is made by looking rather than by imagining.
const preview = (color: string): Record<string, string> => {
const st = cfg?.style ?? 'bar';
const pct = cfg?.intensity ?? 12;
const out: Record<string, string> = {};
if (st === 'tint' || st === 'both') out.backgroundColor = `color-mix(in srgb, ${color} ${pct}%, transparent)`;
if (st === 'bar' || st === 'both') out.boxShadow = `inset 3px 0 0 ${color}`;
return out;
};
if (!cfg) return <div className="p-1 text-sm text-muted-foreground"></div>; if (!cfg) return <div className="p-1 text-sm text-muted-foreground"></div>;
return ( return (
@@ -53,13 +64,35 @@ export function AppearancePanel() {
</label> </label>
{cfg.enabled && ( {cfg.enabled && (
<div className="space-y-2"> <div className="space-y-3">
{/* Style first: it decides whether the colours below are a signal or a
wallpaper, which matters more than which hue they are. */}
<div className="flex items-center gap-3 flex-wrap">
<span className="text-sm">{t('appr.style')}</span>
<div className="inline-flex rounded-md border border-border overflow-hidden text-xs">
{(['bar', 'tint', 'both'] as const).map((v) => (
<button key={v} type="button" onClick={() => save({ ...cfg, style: v })}
className={cn('px-3 py-1.5 font-medium', (cfg.style ?? 'bar') === v ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
{t('appr.style' + v[0].toUpperCase() + v.slice(1))}
</button>
))}
</div>
{(cfg.style ?? 'bar') !== 'bar' && (
<label className="flex items-center gap-2 text-sm">
{t('appr.intensity')}
<input type="range" min={5} max={45} step={1} value={cfg.intensity ?? 12}
onChange={(e) => save({ ...cfg, intensity: parseInt(e.target.value, 10) })}
className="w-32 accent-[var(--primary)]" />
<span className="font-mono text-xs text-muted-foreground w-8">{cfg.intensity ?? 12}%</span>
</label>
)}
</div>
{/* Order matters and is shown: a contact is usually several of these at {/* Order matters and is shown: a contact is usually several of these at
once, and the first match wins. */} once, and the first match wins. */}
<p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p> <p className="text-xs text-muted-foreground">{t('appr.orderHint')}</p>
{cfg.rules.map((r, i) => ( {cfg.rules.map((r, i) => (
<div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2" <div key={r.id} className="rounded-lg border border-border/60 p-2.5 space-y-2"
style={{ backgroundColor: r.enabled ? `color-mix(in srgb, ${r.color} 24%, transparent)` : undefined }}> style={r.enabled ? preview(r.color) : undefined}>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} /> <Checkbox checked={r.enabled} onCheckedChange={(c) => patchRule(r.id, { enabled: !!c })} />
<span className="font-mono text-xs text-muted-foreground">{i + 1}.</span> <span className="font-mono text-xs text-muted-foreground">{i + 1}.</span>
+2 -2
View File
@@ -114,7 +114,7 @@ const en: Dict = {
'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists', 'nav.user': 'User Configuration', 'nav.software': 'Software Configuration', 'nav.hardware': 'Hardware Configuration', 'nav.lists': 'Lists',
'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions', 'sec.station': 'Station Information', 'sec.profiles': 'Profiles', 'sec.operating': 'Operating conditions',
'sec.confirmations': 'Confirmations', 'sec.external': 'External services', 'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.confirmedLotw': 'Confirmed on LoTW', 'appr.confirmedPaper': 'Confirmed by card or eQSL', 'appr.sentWaiting': 'Sent, no answer yet', 'appr.toSend': 'Queued to send (card, LoTW or eQSL)', 'appr.custom': 'Pick any colour', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup', 'sec.general': 'General', 'sec.appearance': 'Appearance', 'appr.enable': 'Colour whole rows by QSL status', 'appr.enableHint': '(in the log grid, like Logger32)', 'appr.orderHint': 'A contact is often several of these at once — the first rule that matches decides the colour.', 'appr.confirmedLotw': 'Confirmed on LoTW', 'appr.confirmedPaper': 'Confirmed by card or eQSL', 'appr.sentWaiting': 'Sent, no answer yet', 'appr.toSend': 'Queued to send (card, LoTW or eQSL)', 'appr.custom': 'Pick any colour', 'appr.style': 'Style', 'appr.styleBar': 'Left stripe', 'appr.styleTint': 'Filled row', 'appr.styleBoth': 'Both', 'appr.intensity': 'Strength', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster', 'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties', 'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'sec.uscounties': 'US Counties',
'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor', 'sec.webpublish': 'Web publishing', 'wpub.hint': 'Publishes your log as a file for a website: a standalone HTML page or a CSV, written locally and optionally uploaded by FTP. It is refreshed when you log a QSO and, if you set an interval, on a timer.', 'wpub.enable': 'Publish the log to a file', 'wpub.fileSection': 'The file', 'wpub.format': 'Format', 'wpub.formatHtml': 'HTML page', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Output folder', 'wpub.browse': 'Browse…', 'wpub.fileName': 'File name', 'wpub.title': 'Page title', 'wpub.titlePh': 'blank = your callsign', 'wpub.count': 'Last N QSOs', 'wpub.every': 'Refresh every', 'wpub.everyHint': 'minutes — 0 = only when a QSO is logged', 'wpub.columns': 'Columns', 'wpub.columnsCount': '{n} of {total} chosen', 'wpub.columnsPick': 'Choose columns…', 'wpub.columnsSearch': 'Search a field…', 'wpub.removeColumn': 'Click to remove', 'wpub.columnsHint': 'Click to add or remove. The order shown here is the order in the file.', 'wpub.ftpEnable': 'Upload by FTP', 'wpub.ftpHost': 'Server / port', 'wpub.ftpUser': 'User', 'wpub.ftpPassword': 'Password', 'wpub.ftpFolder': 'Remote folder', 'wpub.ftpFileName': 'Remote file name', 'wpub.ftpTls': 'Use TLS (FTPS)', 'wpub.publishNow': 'Publish now', 'wpub.testFtp': 'Test connection', 'wpub.lastRun': 'Last run:', 'sec.adifmon': 'ADIF monitor',
@@ -541,7 +541,7 @@ const fr: Dict = {
'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes', 'nav.user': 'Configuration utilisateur', 'nav.software': 'Configuration logicielle', 'nav.hardware': 'Configuration matérielle', 'nav.lists': 'Listes',
'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération", 'sec.station': 'Informations station', 'sec.profiles': 'Profils', 'sec.operating': "Conditions d'opération",
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes', 'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.confirmedLotw': 'Confirmé sur LoTW', 'appr.confirmedPaper': 'Confirmé par carte ou eQSL', 'appr.sentWaiting': 'Envoyé, sans réponse', 'appr.toSend': 'En attente d envoi (carte, LoTW ou eQSL)', 'appr.custom': 'Choisir une couleur', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif", 'sec.general': 'Général', 'sec.appearance': 'Apparence', 'appr.enable': 'Colorer les lignes entières selon le statut QSL', 'appr.enableHint': '(dans le tableau du log, comme Logger32)', 'appr.orderHint': "Un contact est souvent plusieurs de ces états à la fois — la première règle qui correspond décide de la couleur.", 'appr.confirmedLotw': 'Confirmé sur LoTW', 'appr.confirmedPaper': 'Confirmé par carte ou eQSL', 'appr.sentWaiting': 'Envoyé, sans réponse', 'appr.toSend': 'En attente d envoi (carte, LoTW ou eQSL)', 'appr.custom': 'Choisir une couleur', 'appr.style': 'Style', 'appr.styleBar': 'Barre à gauche', 'appr.styleTint': 'Ligne remplie', 'appr.styleBoth': 'Les deux', 'appr.intensity': 'Intensité', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster', 'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US', 'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'sec.uscounties': 'Comtés US',
'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF', 'sec.webpublish': 'Publication web', 'wpub.hint': "Publie ton journal dans un fichier destiné à un site web : une page HTML autonome ou un CSV, écrit en local et envoyé par FTP si tu le souhaites. Il est rafraîchi à chaque QSO enregistré et, si tu règles un intervalle, périodiquement.", 'wpub.enable': 'Publier le journal dans un fichier', 'wpub.fileSection': 'Le fichier', 'wpub.format': 'Format', 'wpub.formatHtml': 'Page HTML', 'wpub.formatCsv': 'CSV', 'wpub.folder': 'Dossier de sortie', 'wpub.browse': 'Parcourir…', 'wpub.fileName': 'Nom du fichier', 'wpub.title': 'Titre de la page', 'wpub.titlePh': 'vide = ton indicatif', 'wpub.count': 'N derniers QSO', 'wpub.every': 'Rafraîchir toutes les', 'wpub.everyHint': 'minutes — 0 = seulement à chaque QSO', 'wpub.columns': 'Colonnes', 'wpub.columnsCount': '{n} sur {total} choisis', 'wpub.columnsPick': 'Choisir les colonnes…', 'wpub.columnsSearch': 'Chercher un champ…', 'wpub.removeColumn': 'Cliquer pour retirer', 'wpub.columnsHint': 'Clique pour ajouter ou retirer. L ordre affiché ici est celui du fichier.', 'wpub.ftpEnable': 'Envoyer par FTP', 'wpub.ftpHost': 'Serveur / port', 'wpub.ftpUser': 'Utilisateur', 'wpub.ftpPassword': 'Mot de passe', 'wpub.ftpFolder': 'Dossier distant', 'wpub.ftpFileName': 'Nom du fichier distant', 'wpub.ftpTls': 'Utiliser TLS (FTPS)', 'wpub.publishNow': 'Publier maintenant', 'wpub.testFtp': 'Tester la connexion', 'wpub.lastRun': 'Dernière exécution :', 'sec.adifmon': 'Moniteur ADIF',
+30 -10
View File
@@ -5,11 +5,16 @@
// "sent, awaiting reply". // "sent, awaiting reply".
export type RowColorRule = { id: string; color: string; enabled: boolean }; export type RowColorRule = { id: string; color: string; enabled: boolean };
export type RowColorSettings = { enabled: boolean; rules: RowColorRule[] }; export type RowColorSettings = {
enabled: boolean;
style?: 'bar' | 'tint' | 'both';
intensity?: number;
rules: RowColorRule[];
};
// ADIF QSL fields are single letters. Y is the only one that means "yes"; // ADIF QSL fields are single letters. Y is the only one that means "yes";
// R (requested) and Q (queued) mean a card is owed, which is a different state // R (requested) and Q (queued) mean it has not gone out yet, which is a
// and the one an operator is looking for when deciding what to post. // different state and the one an operator looks for when deciding what to send.
const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y'; const yes = (v: any) => String(v ?? '').trim().toUpperCase() === 'Y';
const owed = (v: any) => { const owed = (v: any) => {
const s = String(v ?? '').trim().toUpperCase(); const s = String(v ?? '').trim().toUpperCase();
@@ -22,20 +27,35 @@ export function matchRowRule(q: any): string | null {
if (yes(q.qsl_rcvd) || yes(q.eqsl_rcvd)) return 'confirmed_paper'; if (yes(q.qsl_rcvd) || yes(q.eqsl_rcvd)) return 'confirmed_paper';
if (yes(q.qsl_sent) || yes(q.lotw_sent) || yes(q.eqsl_sent)) return 'sent_waiting'; if (yes(q.qsl_sent) || yes(q.lotw_sent) || yes(q.eqsl_sent)) return 'sent_waiting';
// Any route still queued, not just the paper card. LoTW marks a pending upload // Any route still queued, not just the paper card. LoTW marks a pending upload
// as R, which is the commonest "not gone out yet" state in a digital log and // as R, which is the commonest "not gone out yet" state in a digital log.
// was matching nothing at all while this only looked at qsl_sent.
if (owed(q.qsl_sent) || owed(q.lotw_sent) || owed(q.eqsl_sent)) return 'to_send'; if (owed(q.qsl_sent) || owed(q.lotw_sent) || owed(q.eqsl_sent)) return 'to_send';
return null; return null;
} }
// The colour is applied as a TINT, not a fill. The grid is dark and a solid // The colour is never a fill.
// user-picked colour behind white text is unreadable at exactly the moment it //
// matters — Logger32 gets away with it because its grid is white. // A log where nearly every contact has SOME QSL state ends up with every row
export function rowStyleFor(q: any, cfg: RowColorSettings | null): { backgroundColor: string } | undefined { // painted, and colour that is always present stops being information — it
// becomes a striped background with the data behind it. The default is a stripe
// down the left edge: same signal, nothing lost to read it. A tint is offered
// for operators who want the block, at a strength they choose.
export function rowStyleFor(q: any, cfg: RowColorSettings | null): Record<string, string> | undefined {
if (!cfg?.enabled) return undefined; if (!cfg?.enabled) return undefined;
const id = matchRowRule(q); const id = matchRowRule(q);
if (!id) return undefined; if (!id) return undefined;
const rule = cfg.rules?.find((r) => r.id === id); const rule = cfg.rules?.find((r) => r.id === id);
if (!rule?.enabled || !rule.color) return undefined; if (!rule?.enabled || !rule.color) return undefined;
return { backgroundColor: `color-mix(in srgb, ${rule.color} 24%, transparent)` };
const style = cfg.style ?? 'bar';
const pct = Math.max(5, Math.min(45, cfg.intensity ?? 12));
const out: Record<string, string> = {};
if (style === 'tint' || style === 'both') {
out.backgroundColor = `color-mix(in srgb, ${rule.color} ${pct}%, transparent)`;
}
if (style === 'bar' || style === 'both') {
// inset shadow rather than a border: a border would shift the cell layout by
// three pixels on coloured rows only, and the columns would no longer line up.
out.boxShadow = `inset 3px 0 0 ${rule.color}`;
}
return out;
} }
+4
View File
@@ -3010,6 +3010,8 @@ export namespace main {
} }
export class RowColorSettings { export class RowColorSettings {
enabled: boolean; enabled: boolean;
style: string;
intensity: number;
rules: RowColorRule[]; rules: RowColorRule[];
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
@@ -3019,6 +3021,8 @@ export namespace main {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.enabled = source["enabled"]; this.enabled = source["enabled"];
this.style = source["style"];
this.intensity = source["intensity"];
this.rules = this.convertValues(source["rules"], RowColorRule); this.rules = this.convertValues(source["rules"], RowColorRule);
} }