Opening the Map or Statistics page showed only what had been decoded since the moment it was opened, and a reload — landing straight on the tab, so its module loads at startup ahead of the history — was the only way to see the rest. Two things were being thrown away. The decode log the Statistics page counts lives in the map module, which is lazy. Recording into a module that is not loaded yet is a no-op, and unlike the map markers nothing replayed the log when it finally arrived, so every decode heard before the first visit was simply never counted. Hold those records in the client and hand them over when the module attaches. The map's own replay covered APRS, AIS and VDES, whose plugins implement syncMap, but not the grid squares: the FTx family and WSPR plotted locators as decodes arrived and had no replay at all, so everything they heard before the map loaded was lost, and the unique-grid counter with it. Both plot through a helper now, which their syncMap replays oldest first. A replayed WSPR spot carries the frequency it was heard on rather than one worked out against wherever the dial has moved to since. Pinned in decode-flow, whose history fixture gains FT8 and WSPR spots: after a first visit the statistics count every stored decode and every grid square, which before this change were 0 and 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams <sjg@haxx.space>
282 lines
9.8 KiB
JavaScript
282 lines
9.8 KiB
JavaScript
import {
|
|
forActiveRig,
|
|
isActiveRigDecode
|
|
} from "./chunk-S57W63QN.js";
|
|
import {
|
|
hostCore
|
|
} from "./chunk-KL66PICH.js";
|
|
|
|
// src/plugins/wspr.ts
|
|
var wsprWindow = window;
|
|
var wsprStatus = document.getElementById("wspr-status");
|
|
var wsprPeriodEl = document.getElementById("wspr-period");
|
|
var wsprMessagesEl = document.getElementById("wspr-messages");
|
|
var wsprFilterInput = document.getElementById("wspr-filter");
|
|
var WSPR_PERIOD_SECONDS = 120;
|
|
var wsprFilterText = "";
|
|
var wsprMessageHistory = [];
|
|
function finiteNumber(value) {
|
|
const number = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(number) ? number : null;
|
|
}
|
|
function currentWsprHistoryRetentionMs() {
|
|
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
|
}
|
|
function pruneWsprMessageHistory() {
|
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
|
|
}
|
|
function scheduleWsprHistoryRender() {
|
|
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
|
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
|
renderWsprHistory();
|
|
});
|
|
return;
|
|
}
|
|
renderWsprHistory();
|
|
}
|
|
function fmtWsprTime(tsMs) {
|
|
if (!tsMs) return "--:--:--";
|
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
}
|
|
function updateWsprPeriodTimer() {
|
|
if (!wsprPeriodEl) return;
|
|
const nowSec = Math.floor(Date.now() / 1e3);
|
|
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
|
const ss = String(remaining % 60).padStart(2, "0");
|
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
|
}
|
|
updateWsprPeriodTimer();
|
|
setInterval(updateWsprPeriodTimer, 500);
|
|
function renderWsprRow(msg) {
|
|
const row = document.createElement("div");
|
|
row.className = "ft8-row";
|
|
row.dataset.decoder = "wspr";
|
|
const snr = finiteNumber(msg.snr_db);
|
|
const delta = finiteNumber(msg.dt_s);
|
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
|
const offsetHz = finiteNumber(msg.freq_hz);
|
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
|
|
const freq = rfHz?.toFixed(0) ?? "--";
|
|
const message = msg.message ?? "";
|
|
row.dataset.message = message.toUpperCase();
|
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
|
applyWsprFilterToRow(row);
|
|
return row;
|
|
}
|
|
function renderWsprHistory() {
|
|
pruneWsprMessageHistory();
|
|
if (!wsprMessagesEl) return;
|
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
|
const fragment = document.createDocumentFragment();
|
|
for (const message of rigMessages) {
|
|
fragment.appendChild(renderWsprRow(message));
|
|
}
|
|
wsprMessagesEl.replaceChildren(fragment);
|
|
}
|
|
function addWsprMessage(msg) {
|
|
msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
wsprMessageHistory.unshift(msg);
|
|
pruneWsprMessageHistory();
|
|
scheduleWsprHistoryRender();
|
|
}
|
|
function normalizeServerWsprMessage(msg) {
|
|
const raw = msg.message ?? "";
|
|
const grids = extractAllGrids(raw);
|
|
const station = extractLikelyCallsign(raw);
|
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
|
const offsetHz = finiteNumber(msg.freq_hz);
|
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
|
|
return {
|
|
raw,
|
|
grids,
|
|
station,
|
|
rfHz,
|
|
history: {
|
|
rig_id: msg.rig_id ?? null,
|
|
_rfHz: rfHz,
|
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
|
ts_ms: msg.ts_ms,
|
|
snr_db: msg.snr_db,
|
|
dt_s: msg.dt_s,
|
|
freq_hz: msg.freq_hz,
|
|
message: raw
|
|
}
|
|
};
|
|
}
|
|
function onServerWsprBatch(messages) {
|
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
const normalized = [];
|
|
for (const msg of messages) {
|
|
const next = normalizeServerWsprMessage(msg);
|
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
|
plotWsprLocator(msg);
|
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
|
normalized.push(next.history);
|
|
}
|
|
normalized.reverse();
|
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
|
pruneWsprMessageHistory();
|
|
scheduleWsprHistoryRender();
|
|
}
|
|
function pruneWsprHistoryView() {
|
|
pruneWsprMessageHistory();
|
|
renderWsprHistory();
|
|
}
|
|
function escapeWsprHtml(input) {
|
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
}
|
|
function renderWsprMessage(message) {
|
|
let out = "";
|
|
let i = 0;
|
|
while (i < message.length) {
|
|
const ch = message[i];
|
|
if (isAlphaNum(ch)) {
|
|
let j = i + 1;
|
|
while (j < message.length && isAlphaNum(message[j])) j++;
|
|
const token = message.slice(i, j);
|
|
const grid = token.toUpperCase();
|
|
if (isMaidenheadGridToken(grid)) {
|
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
} else {
|
|
out += escapeWsprHtml(token);
|
|
}
|
|
i = j;
|
|
} else {
|
|
out += escapeWsprHtml(ch ?? "");
|
|
i += 1;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
function extractAllGrids(message) {
|
|
const out = [];
|
|
const seen = /* @__PURE__ */ new Set();
|
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
|
for (const token of parts) {
|
|
if (!token) continue;
|
|
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
|
seen.add(token);
|
|
out.push(token);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
function extractLikelyCallsign(message) {
|
|
const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
|
|
for (const token of parts) {
|
|
if (!token) continue;
|
|
if (token.length < 3 || token.length > 12) continue;
|
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
|
if (isMaidenheadGridToken(token)) continue;
|
|
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
|
}
|
|
return null;
|
|
}
|
|
function isFtxFarewellToken(token) {
|
|
const normalized = token.trim().toUpperCase();
|
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
|
}
|
|
function isMaidenheadGridToken(token) {
|
|
const normalized = token.trim().toUpperCase();
|
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
|
}
|
|
function isAlphaNum(ch) {
|
|
return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
|
|
}
|
|
function activateWsprHistoryLocator(target) {
|
|
if (!(target instanceof Element)) return false;
|
|
const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
|
|
if (!locatorEl) return false;
|
|
const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
|
|
if (!grid) return false;
|
|
if (typeof wsprWindow.navigateToMapLocator === "function") {
|
|
wsprWindow.navigateToMapLocator(grid, "wspr");
|
|
}
|
|
return true;
|
|
}
|
|
function applyWsprFilterToRow(row) {
|
|
if (!wsprFilterText) {
|
|
row.style.display = "";
|
|
return;
|
|
}
|
|
const message = row.dataset.message || "";
|
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
|
}
|
|
function resetWsprHistoryView() {
|
|
if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
|
|
wsprMessageHistory = [];
|
|
renderWsprHistory();
|
|
if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
|
|
}
|
|
if (wsprFilterInput) {
|
|
wsprFilterInput.addEventListener("input", () => {
|
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
|
renderWsprHistory();
|
|
});
|
|
}
|
|
if (wsprMessagesEl) {
|
|
wsprMessagesEl.addEventListener("click", (event) => {
|
|
if (!activateWsprHistoryLocator(event.target)) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
wsprMessagesEl.addEventListener("keydown", (event) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
if (!activateWsprHistoryLocator(event.target)) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
});
|
|
}
|
|
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
|
wsprDecodeToggleBtn?.addEventListener("click", () => {
|
|
void (async () => {
|
|
try {
|
|
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
|
await hostCore.postPath("/toggle_wspr_decode");
|
|
} catch (error) {
|
|
console.error("WSPR toggle failed", error);
|
|
}
|
|
})();
|
|
});
|
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
|
|
void (async () => {
|
|
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
try {
|
|
await hostCore.postPath("/clear_wspr_decode");
|
|
resetWsprHistoryView();
|
|
} catch (error) {
|
|
console.error("WSPR history clear failed", error);
|
|
}
|
|
})();
|
|
});
|
|
function plotWsprLocator(msg) {
|
|
const next = normalizeServerWsprMessage(msg);
|
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
|
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
|
...msg,
|
|
...rfHz === null ? {} : { freq_hz: rfHz }
|
|
});
|
|
}
|
|
function onServerWspr(msg) {
|
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
|
const next = normalizeServerWsprMessage(msg);
|
|
plotWsprLocator(msg);
|
|
addWsprMessage(next.history);
|
|
}
|
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
|
id: "wspr",
|
|
onMessage: onServerWspr,
|
|
onBatch: onServerWsprBatch,
|
|
restore: onServerWsprBatch,
|
|
prune: pruneWsprHistoryView,
|
|
reset: resetWsprHistoryView,
|
|
rerender: renderWsprHistory,
|
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
|
syncMap: () => {
|
|
for (const message of [...wsprMessageHistory].reverse()) plotWsprLocator(message);
|
|
}
|
|
});
|