feat: header rate/propagation colours + CW macro word space

Header: colour the propagation indices semantically (SFI/SSN green when
strong; A/K green quiet → yellow unsettled → red storm) and glow the QSO-rate
numbers the brand accent when active, dim to muted when idle.

WinKeyer/Icom CW: append a trailing word space to each macro send so two
macros fired back-to-back don't run together in the keyer buffer ("CQ"+"TEST"
→ "CQTEST"). The keyer keys the space at the current speed, so it scales with
WPM. Only the macro path is affected — send-on-type stays per-character.
This commit is contained in:
2026-07-19 00:59:05 +02:00
parent da1793a902
commit 19993bafc1
+23 -10
View File
@@ -2008,17 +2008,21 @@ export default function App() {
async function wkSend(rawText: string) {
setWkSent('');
const resolved = resolveCW(rawText);
// Trailing word space so two macros fired back-to-back don't run together in
// the keyer buffer ("CQ" + "TEST" → "CQTEST"). The keyer keys a space as a
// word gap at the CURRENT speed, so it scales with WPM automatically.
const keyed = resolved ? resolved + ' ' : resolved;
const doLog = /<LOGQSO>/i.test(rawText); // resolveCW strips the token (unknown var → "")
const sleep = (ms: number) => new Promise((r) => window.setTimeout(r, ms));
if (cwSourceRef.current === 'icom') {
// The rig's keyer gives no busy echo back, so show the text we sent and,
// for <LOGQSO>, wait the estimated send duration before logging.
setWkSent(resolved);
await IcomSendCW(resolved).catch((e) => setError(String(e?.message ?? e)));
await IcomSendCW(keyed).catch((e) => setError(String(e?.message ?? e)));
if (doLog) { await sleep(Math.round(estimateCwMs(resolved, wkWpm)) + 600); void save(); }
return;
}
await WinkeyerSend(resolved).catch((e) => setError(String(e?.message ?? e)));
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
// <LOGQSO> (e.g. "BK 73 TU <LOGQSO>") logs the contact AFTER the keyer has
// finished sending — so the QSO isn't logged (and the form cleared) while CW
// is still going out. We'd like to wait for the busy flag to rise then fall,
@@ -3760,16 +3764,17 @@ export default function App() {
{showQsoRate && (
<div className="flex items-center gap-2.5 font-mono px-2.5 h-8 rounded-md border border-border/60 bg-muted/40 whitespace-nowrap"
title={t('rate.title')}>
<Activity className="size-3.5 text-muted-foreground" />
<Activity className={cn('size-3.5', (qsoRate.last10 + qsoRate.last60) > 0 ? 'text-primary' : 'text-muted-foreground')} />
{/* Contest-style rate: QSOs/hour projected from each window
(10-min count ×6; the 60-min count is already per hour). */}
(10-min count ×6; the 60-min count is already per hour). Numbers
glow the brand accent when active, dim to muted when idle. */}
<span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">10</span>
<span className="font-bold text-[12px] text-foreground">{qsoRate.last10 * 6}</span>
<span className={cn('font-bold text-[12px]', qsoRate.last10 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last10 * 6}</span>
</span>
<span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">60</span>
<span className="font-bold text-[12px] text-foreground">{qsoRate.last60}</span>
<span className={cn('font-bold text-[12px]', qsoRate.last60 > 0 ? 'text-primary' : 'text-muted-foreground')}>{qsoRate.last60}</span>
</span>
<span className="text-muted-foreground text-[9px] uppercase tracking-wider">Q/h</span>
</div>
@@ -3786,6 +3791,14 @@ export default function App() {
const geo = String(solar.geomag_field || '').toUpperCase();
const geoCls = /STORM|SEVERE/.test(geo) ? 'text-danger'
: /ACTIVE|UNSETTLED/.test(geo) ? 'text-warning' : 'text-success';
const num = (v: any) => { const n = Number(v); return Number.isFinite(n) ? n : null; };
// Semantic colour by band condition: higher flux/sunspots = better HF
// (green when strong); A and K measure geomagnetic disturbance, so LOW
// is good (green quiet → yellow unsettled → red storm).
const sfiCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n >= 120 ? 'text-success' : n >= 90 ? 'text-foreground' : 'text-warning'; };
const ssnCls = (v: any) => { const n = num(v); return n != null && n >= 80 ? 'text-success' : 'text-foreground'; };
const aCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 7 ? 'text-success' : n <= 15 ? 'text-warning' : 'text-danger'; };
const kCls = (v: any) => { const n = num(v); return n == null ? 'text-foreground' : n <= 2 ? 'text-success' : n <= 3 ? 'text-warning' : 'text-danger'; };
const it = (label: string, val: any, cls = 'text-foreground') => (
<span className="inline-flex items-baseline gap-1">
<span className="text-muted-foreground uppercase tracking-wider text-[9px]">{label}</span>
@@ -3793,10 +3806,10 @@ export default function App() {
</span>
);
return (<>
{it('SFI', solar.sfi)}
{it('SSN', solar.ssn)}
{it('A', solar.a_index)}
{it('K', solar.k_index)}
{it('SFI', solar.sfi, sfiCls(solar.sfi))}
{it('SSN', solar.ssn, ssnCls(solar.ssn))}
{it('A', solar.a_index, aCls(solar.a_index))}
{it('K', solar.k_index, kCls(solar.k_index))}
{geo ? <span className={cn('font-bold text-[12px]', geoCls)}>{geo}</span> : null}
</>);
})()}