Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17300170cc | ||
|
|
e41c13917d | ||
|
|
ae7df31d91 | ||
|
|
bf3bcc8a84 | ||
|
|
86dd36312e | ||
|
|
15ff686542 | ||
|
|
b78c4a4dd4 | ||
|
|
f396e8f235 |
@@ -454,6 +454,29 @@ listener and every rig's audio listener.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Multiple Rigs in the Web UI
|
||||||
|
|
||||||
|
A client connected to several rigs decodes all of them at once, whichever one
|
||||||
|
is on screen. The rig picker in the header decides which rig the UI is about,
|
||||||
|
and each page answers that differently:
|
||||||
|
|
||||||
|
| Page | Shows |
|
||||||
|
|------|-------|
|
||||||
|
| Radio | The selected rig: its spectrum, its audio, and the mini decode views over the waterfall. |
|
||||||
|
| Digital Modes | The selected rig: every decoder panel, its counts and its status line. |
|
||||||
|
| Map | The whole station — every rig's positions, with the map's own rig filter to narrow it. HF APRS is a source of its own there, filtered apart from VHF APRS. |
|
||||||
|
| Statistics | The whole station, including the per-rig comparison. |
|
||||||
|
|
||||||
|
Switching rigs repaints the radio and digital modes pages for the rig now
|
||||||
|
selected. Nothing is lost by switching: the traffic other rigs heard is still
|
||||||
|
held, and switching back brings it up again. The one exception is the CW pane,
|
||||||
|
which is a single running stream of copied text rather than a list of frames,
|
||||||
|
so it starts empty on the rig you switch to.
|
||||||
|
|
||||||
|
Each browser tab keeps its own selection, so two tabs can watch two rigs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Tune Links
|
## Tune Links
|
||||||
|
|
||||||
Every page of the web UI carries what the radio is doing in its address, so the
|
Every page of the web UI carries what the radio is doing in its address, so the
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -140,18 +144,22 @@ function aisLatestByVessel(messages) {
|
|||||||
}
|
}
|
||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
|
function aisRigMessages() {
|
||||||
|
return forActiveRig(aisMessageHistory);
|
||||||
|
}
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = aisMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -222,7 +230,9 @@ function updateAisBar() {
|
|||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)
|
||||||
|
);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -267,7 +277,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (const message of aisRigMessages()) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -299,10 +309,10 @@ function normalizeServerAisMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerAisBatch(messages) {
|
function onServerAisBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
|
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -342,8 +352,9 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAis(msg) {
|
function onServerAis(msg) {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
const message = normalizeServerAisMessage(msg);
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
}
|
}
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -353,6 +364,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
},
|
||||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
||||||
|
|||||||
@@ -885,7 +885,7 @@ function elementById(id) {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
@@ -1065,6 +1065,9 @@ var runtime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
rerenderAll() {
|
||||||
|
for (const plugin of decoders.values()) plugin.rerender?.();
|
||||||
|
},
|
||||||
syncMapAll() {
|
syncMapAll() {
|
||||||
for (const plugin of decoders.values()) plugin.syncMap?.();
|
for (const plugin of decoders.values()) plugin.syncMap?.();
|
||||||
},
|
},
|
||||||
@@ -1529,6 +1532,7 @@ var TAB_ORDER = [
|
|||||||
"bookmarks",
|
"bookmarks",
|
||||||
"digital-modes",
|
"digital-modes",
|
||||||
"map",
|
"map",
|
||||||
|
"satellites",
|
||||||
"statistics",
|
"statistics",
|
||||||
"recorder",
|
"recorder",
|
||||||
"settings",
|
"settings",
|
||||||
@@ -1539,6 +1543,7 @@ var TAB_PATHS = {
|
|||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
|
satellites: "/satellites",
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
@@ -1756,6 +1761,7 @@ var pluginGroups = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
|
satellites: ["/satellite-predictions.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
@@ -3552,6 +3558,13 @@ function positionRdsPsOverlay() {
|
|||||||
function resetRdsDisplay() {
|
function resetRdsDisplay() {
|
||||||
updateRdsPsOverlay(primaryRds);
|
updateRdsPsOverlay(primaryRds);
|
||||||
}
|
}
|
||||||
|
function refreshDecodeBars() {
|
||||||
|
window.updateAisBar?.();
|
||||||
|
window.updateVdesBar?.();
|
||||||
|
window.updateAprsBar?.();
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
window.updateCwBar?.();
|
||||||
|
}
|
||||||
function resetDecoderStateOnRigSwitch() {
|
function resetDecoderStateOnRigSwitch() {
|
||||||
primaryRds = null;
|
primaryRds = null;
|
||||||
vchanRdsById = /* @__PURE__ */ new Map();
|
vchanRdsById = /* @__PURE__ */ new Map();
|
||||||
@@ -3566,6 +3579,8 @@ function resetDecoderStateOnRigSwitch() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = "--";
|
if (el) el.textContent = "--";
|
||||||
});
|
});
|
||||||
|
refreshDecodeBars();
|
||||||
|
window.trxPluginRuntime.rerenderAll();
|
||||||
}
|
}
|
||||||
function resetWfmStereoIndicator() {
|
function resetWfmStereoIndicator() {
|
||||||
if (!wfmStFlagEl) return;
|
if (!wfmStFlagEl) return;
|
||||||
@@ -4785,10 +4800,7 @@ function render(update) {
|
|||||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||||
}
|
}
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
refreshDecodeBars();
|
||||||
if (window.updateVdesBar) window.updateVdesBar();
|
|
||||||
if (window.updateAprsBar) window.updateAprsBar();
|
|
||||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
|
||||||
for (const d of decoderRegistry) {
|
for (const d of decoderRegistry) {
|
||||||
if (d.activation !== "toggle") continue;
|
if (d.activation !== "toggle") continue;
|
||||||
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
const key = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
@@ -5844,6 +5856,7 @@ function _initMapWhenReady() {
|
|||||||
}
|
}
|
||||||
function navigateToTab(name, options = {}) {
|
function navigateToTab(name, options = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
|
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
@@ -5873,7 +5886,11 @@ function navigateToTab(name, options = {}) {
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
void loadPluginsForTab(name).catch((error) => {
|
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||||
|
void loadPluginsForTab(name).then(() => {
|
||||||
|
if (name === "satellites") window.refreshSatPredictions?.();
|
||||||
|
flushPendingDecodeStats();
|
||||||
|
}).catch((error) => {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
});
|
});
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
@@ -6320,9 +6337,6 @@ function _wireSubTabBar(bar) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
|
||||||
window.clearSatPredictionDom();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
@@ -7606,11 +7620,35 @@ var IMAGE_DECODE_KINDS = /* @__PURE__ */ new Set([
|
|||||||
"sstv",
|
"sstv",
|
||||||
"sstv_progress"
|
"sstv_progress"
|
||||||
]);
|
]);
|
||||||
|
var pendingDecodeStats = [];
|
||||||
|
var PENDING_DECODE_STATS_MAX = 5e4;
|
||||||
|
function recordDecodeStat(kind, rig, tsMs) {
|
||||||
|
const stats = window.trx.modules.map;
|
||||||
|
if (stats) {
|
||||||
|
stats.statsRecordDecode(kind, rig, tsMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||||
|
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||||
|
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function flushPendingDecodeStats() {
|
||||||
|
const stats = window.trx.modules.map;
|
||||||
|
if (!stats || pendingDecodeStats.length === 0) return;
|
||||||
|
for (const entry of pendingDecodeStats.splice(0)) {
|
||||||
|
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||||
|
}
|
||||||
|
stats.scheduleStatsRender();
|
||||||
|
}
|
||||||
|
function scheduleStatsRenderIfLoaded() {
|
||||||
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
|
}
|
||||||
function dispatchDecodeMessage(msg, skipStats = false) {
|
function dispatchDecodeMessage(msg, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
var DECODE_HISTORY_WORKER_GROUP_LIMIT = 512;
|
||||||
@@ -7656,9 +7694,9 @@ function restoreDecodeHistoryGroup(kind, messages) {
|
|||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || void 0);
|
||||||
}
|
}
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
window.trxPluginRuntime.restore(kind, messages);
|
window.trxPluginRuntime.restore(kind, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -82,20 +86,25 @@ function aprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
function aprsRigPackets() {
|
||||||
|
return forActiveRig(aprsPacketHistory);
|
||||||
|
}
|
||||||
function aprsVisiblePackets() {
|
function aprsVisiblePackets() {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = aprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -153,7 +162,9 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id)
|
||||||
|
);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -215,11 +226,11 @@ function normalizeServerAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerAprsBatch(packets) {
|
function onServerAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -277,8 +288,9 @@ if (aprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerAprs(pkt) {
|
function onServerAprs(pkt) {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
}
|
}
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -288,6 +300,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
},
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry);
|
||||||
|
|||||||
+33
-12
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -153,9 +157,10 @@ function initializeFtxDecoder(config) {
|
|||||||
const render = () => {
|
const render = () => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
|
const rigMessages = forActiveRig(history);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of history) {
|
for (const message of rigMessages) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -163,20 +168,26 @@ function initializeFtxDecoder(config) {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const normalize = (message) => {
|
const plotLocator = (message) => {
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
if (grids.length === 0) return;
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
if (grids.length > 0) {
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
...message,
|
||||||
...message,
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
locator_details: locatorDetails
|
||||||
locator_details: locatorDetails
|
});
|
||||||
});
|
};
|
||||||
}
|
const normalize = (message) => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
return {
|
return {
|
||||||
|
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||||
|
// rig on screen from one a background rig made on another band.
|
||||||
|
rig_id: message.rig_id ?? null,
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -188,7 +199,9 @@ function initializeFtxDecoder(config) {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages) => {
|
const receiveBatch = (messages) => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status) status.textContent = "Receiving";
|
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||||
|
status.textContent = "Receiving";
|
||||||
|
}
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -202,7 +215,7 @@ function initializeFtxDecoder(config) {
|
|||||||
bridge.clearMapMarkersByType?.(id);
|
bridge.clearMapMarkersByType?.(id);
|
||||||
};
|
};
|
||||||
const barFrames = () => {
|
const barFrames = () => {
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5 && isActiveRigDecode(message.rig_id)).slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
@@ -226,7 +239,15 @@ function initializeFtxDecoder(config) {
|
|||||||
prune();
|
prune();
|
||||||
render();
|
render();
|
||||||
},
|
},
|
||||||
reset
|
reset,
|
||||||
|
rerender: () => {
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const message of [...history].reverse()) plotLocator(message);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
const updatePeriod = () => {
|
const updatePeriod = () => {
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import {
|
||||||
|
hostState
|
||||||
|
} from "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
|
// src/plugins/active-rig.ts
|
||||||
|
function isActiveRigDecode(rigId) {
|
||||||
|
const activeRigId = hostState.lastActiveRigId;
|
||||||
|
if (!activeRigId || !rigId) return true;
|
||||||
|
return rigId === activeRigId;
|
||||||
|
}
|
||||||
|
function forActiveRig(items) {
|
||||||
|
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
isActiveRigDecode,
|
||||||
|
forActiveRig
|
||||||
|
};
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import {
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -25,7 +28,7 @@ var CW_BAR_LINE_GAP_MS = 5e3;
|
|||||||
var cwLastAppendTime = 0;
|
var cwLastAppendTime = 0;
|
||||||
var cwTonePickerRaf = null;
|
var cwTonePickerRaf = null;
|
||||||
var cwBarHistory = [];
|
var cwBarHistory = [];
|
||||||
var cwBarCurrentLine = null;
|
var cwBarCurrentLines = /* @__PURE__ */ new Map();
|
||||||
var cwBarDismissedAtMs = 0;
|
var cwBarDismissedAtMs = 0;
|
||||||
var cwAutoLocalOverride = null;
|
var cwAutoLocalOverride = null;
|
||||||
function escapeCwHtml(input) {
|
function escapeCwHtml(input) {
|
||||||
@@ -50,20 +53,22 @@ cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
|||||||
if (cwAutoLocalOverride !== null) return;
|
if (cwAutoLocalOverride !== null) return;
|
||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
function cwBarFlushCurrentLine() {
|
function cwBarFlushCurrentLine(key) {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
const line = cwBarCurrentLines.get(key);
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarCurrentLines.delete(key);
|
||||||
|
if (line?.text.trim()) {
|
||||||
|
cwBarHistory.unshift(line);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
}
|
||||||
function updateCwBar() {
|
function updateCwBar() {
|
||||||
if (!cwBarOverlay) return;
|
if (!cwBarOverlay) return;
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const inProgress = [...cwBarCurrentLines.values()].filter((l) => l.text && isActiveRigDecode(l.rigId)).sort((a, b) => b.tsMs - a.tsMs);
|
||||||
|
const liveLines = [...inProgress, ...recent];
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -308,7 +313,7 @@ function resetCwHistoryView() {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -324,8 +329,9 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerCw(evt) {
|
function onServerCw(evt) {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||||
if (evt.text && cwOutputEl) {
|
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||||
const line = document.createElement("div");
|
const line = document.createElement("div");
|
||||||
@@ -346,25 +352,29 @@ function onServerCw(evt) {
|
|||||||
}
|
}
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const rigId = evt.rig_id ?? null;
|
||||||
|
const key = rigId ?? "";
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine(key);
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -389,7 +399,14 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
id: "cw",
|
id: "cw",
|
||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView
|
reset: resetCwHistoryView,
|
||||||
|
// The copied text of a rig that is no longer on screen cannot be unpicked
|
||||||
|
// from the pane, so the switch starts the new rig's stream from empty.
|
||||||
|
rerender: () => {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
updateCwBar();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-PJ5Q7CWJ.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft2.ts
|
// src/plugins/ft2.ts
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
initializeFtxDecoder
|
initializeFtxDecoder
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-PJ5Q7CWJ.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft4.ts
|
// src/plugins/ft4.ts
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import {
|
|||||||
initializeFt8FamilyBar,
|
initializeFt8FamilyBar,
|
||||||
initializeFtxDecoder,
|
initializeFtxDecoder,
|
||||||
installFtxCompatibilityHelpers
|
installFtxCompatibilityHelpers
|
||||||
} from "./chunk-O2Y7YEVQ.js";
|
} from "./chunk-PJ5Q7CWJ.js";
|
||||||
|
import "./chunk-S57W63QN.js";
|
||||||
import "./chunk-KL66PICH.js";
|
import "./chunk-KL66PICH.js";
|
||||||
|
|
||||||
// src/plugins/ft8.ts
|
// src/plugins/ft8.ts
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
normalizeAprsPacket,
|
normalizeAprsPacket,
|
||||||
renderAprsPacketRow
|
renderAprsPacketRow
|
||||||
} from "./chunk-OPEIVJGD.js";
|
} from "./chunk-OPEIVJGD.js";
|
||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore,
|
hostCore,
|
||||||
hostState
|
hostState
|
||||||
@@ -67,21 +71,26 @@ function hfAprsFilterMatch(pkt) {
|
|||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
function hfAprsRigPackets() {
|
||||||
|
return forActiveRig(hfAprsPacketHistory);
|
||||||
|
}
|
||||||
function hfAprsVisiblePackets() {
|
function hfAprsVisiblePackets() {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = hfAprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -140,17 +149,32 @@ function resetHfAprsHistoryView() {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
}
|
}
|
||||||
function pruneHfAprsHistoryView() {
|
function pruneHfAprsHistoryView() {
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
|
function plotHfAprsPacket(pkt) {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||||
|
hfAprsWindow.aprsMapAddStation(
|
||||||
|
pkt.srcCall ?? "",
|
||||||
|
pkt.lat,
|
||||||
|
pkt.lon,
|
||||||
|
pkt.info ?? "",
|
||||||
|
pkt.symbolTable,
|
||||||
|
pkt.symbolCode,
|
||||||
|
pkt,
|
||||||
|
"hf_aprs"
|
||||||
|
);
|
||||||
|
}
|
||||||
function addHfAprsPacket(pkt) {
|
function addHfAprsPacket(pkt) {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
function normalizeServerHfAprsPacket(pkt) {
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
@@ -158,13 +182,14 @@ function normalizeServerHfAprsPacket(pkt) {
|
|||||||
}
|
}
|
||||||
function onServerHfAprsBatch(packets) {
|
function onServerHfAprsBatch(packets) {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
|
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -227,8 +252,9 @@ if (hfAprsFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
function onServerHfAprs(pkt) {
|
function onServerHfAprs(pkt) {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
}
|
}
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
window.trxPluginRuntime.registerDecoder({
|
||||||
@@ -237,5 +263,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerHfAprsBatch,
|
onBatch: onServerHfAprsBatch,
|
||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView
|
prune: pruneHfAprsHistoryView,
|
||||||
|
rerender: renderHfAprsHistory,
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => {
|
||||||
|
for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ var mapWindow = window;
|
|||||||
const decodeContactPaths = /* @__PURE__ */ new Map();
|
const decodeContactPaths = /* @__PURE__ */ new Map();
|
||||||
let selectedMapQsoKey = null;
|
let selectedMapQsoKey = null;
|
||||||
const mapMarkers = /* @__PURE__ */ new Set();
|
const mapMarkers = /* @__PURE__ */ new Set();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
const MAP_FILTER_ALL_KEY = "__all";
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() };
|
||||||
@@ -128,6 +128,12 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
function aprsEntrySource(entry) {
|
||||||
|
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
}
|
||||||
|
function aprsStationKey(call, source) {
|
||||||
|
return source === "aprs" ? call : `${source}:${call}`;
|
||||||
|
}
|
||||||
function refreshAprsTrack(call, entry) {
|
function refreshAprsTrack(call, entry) {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -149,7 +155,7 @@ var mapWindow = window;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false
|
interactive: false
|
||||||
});
|
});
|
||||||
track.__trxType = "aprs";
|
track.__trxType = aprsEntrySource(entry);
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -381,6 +387,7 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
function mapSourceLabel(type) {
|
function mapSourceLabel(type) {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
|
if (type === "hf_aprs") return "HF APRS";
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
function locatorFilterColor(type) {
|
function locatorFilterColor(type) {
|
||||||
@@ -396,6 +403,7 @@ var mapWindow = window;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
|
if (type === "hf_aprs") return "#fb7185";
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
function bandForHz(hz) {
|
function bandForHz(hz) {
|
||||||
@@ -931,10 +939,8 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
if (!entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add("aprs");
|
availableSources.add(aprsEntrySource(entry));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const bandMap = /* @__PURE__ */ new Map();
|
const bandMap = /* @__PURE__ */ new Map();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -966,7 +972,7 @@ var mapWindow = window;
|
|||||||
for (const key of Array.from(mapLocatorFilter.bands)) {
|
for (const key of Array.from(mapLocatorFilter.bands)) {
|
||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
const sourceItems = ["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({
|
||||||
key,
|
key,
|
||||||
label: mapSourceLabel(key),
|
label: mapSourceLabel(key),
|
||||||
color: mapSourceColor(key),
|
color: mapSourceColor(key),
|
||||||
@@ -1028,9 +1034,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(call);
|
const entry = stationMarkers.get(key);
|
||||||
|
const call = entry?.call ?? key;
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1200,9 +1207,10 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry) => {
|
stationMarkers.forEach((entry, key) => {
|
||||||
|
if (aprsEntrySource(entry) !== type) return;
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1211,8 +1219,8 @@ var mapWindow = window;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
|
stationMarkers.delete(key);
|
||||||
});
|
});
|
||||||
stationMarkers.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (type === "ais") {
|
if (type === "ais") {
|
||||||
@@ -1448,13 +1456,15 @@ var mapWindow = window;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? String(marker._aprsCall);
|
||||||
|
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (marker._aisMmsi) {
|
if (marker._aisMmsi) {
|
||||||
@@ -1848,28 +1858,33 @@ var mapWindow = window;
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
function _aprsAddMarkerToMap(call, entry) {
|
function _aprsAddMarkerToMap(key, entry) {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(call, entry);
|
refreshAprsTrack(key, entry);
|
||||||
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? key;
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
|
const color = mapSourceColor(source);
|
||||||
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6,
|
radius: 6,
|
||||||
color: "#00d17f",
|
color,
|
||||||
fillColor: "#00d17f",
|
fillColor: color,
|
||||||
fillOpacity: 0.8
|
fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent);
|
}).addTo(aprsMap).bindPopup(popupContent);
|
||||||
marker.__trxType = "aprs";
|
marker.__trxType = source;
|
||||||
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set();
|
||||||
marker._aprsCall = call;
|
marker._aprsCall = key;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||||
const nextPoint = [lat, lon];
|
const nextPoint = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const existing = stationMarkers.get(call);
|
const stationSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
const key = aprsStationKey(call, stationSource);
|
||||||
|
const existing = stationMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -1888,7 +1903,8 @@ var mapWindow = window;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
existing.call = call;
|
||||||
|
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -1899,7 +1915,8 @@ var mapWindow = window;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: "aprs",
|
type: stationSource,
|
||||||
|
call,
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -1908,9 +1925,9 @@ var mapWindow = window;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
rigIds: new Set(msgRigId ? [msgRigId] : [])
|
||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(key, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ var satDom = {
|
|||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -17,29 +16,12 @@ var satDom = {
|
|||||||
typeFilter: document.getElementById("sat-type-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history")
|
||||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
|
||||||
predFilter: document.getElementById("sat-pred-filter"),
|
|
||||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
|
||||||
predCategory: document.getElementById("sat-pred-category"),
|
|
||||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
|
||||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
|
||||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
|
||||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
|
||||||
predStatus: document.getElementById("sat-pred-status")
|
|
||||||
};
|
};
|
||||||
var satImageHistory = [];
|
var satImageHistory = [];
|
||||||
var SAT_MAX_IMAGES = 100;
|
var SAT_MAX_IMAGES = 100;
|
||||||
var SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
var satPredShowAll = false;
|
|
||||||
var satFilterText = "";
|
var satFilterText = "";
|
||||||
var satActiveView = "live";
|
var satActiveView = "live";
|
||||||
var satPredData = [];
|
|
||||||
var satPredFilterText = "";
|
|
||||||
var satPredMinEl = 0;
|
|
||||||
var satPredCategory = "all";
|
|
||||||
var satPredSatCount = 0;
|
|
||||||
var satPredCountdownTimer = null;
|
|
||||||
function scheduleSatUi(key, job) {
|
function scheduleSatUi(key, job) {
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
@@ -48,37 +30,19 @@ function scheduleSatUi(key, job) {
|
|||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
function switchSatView(view) {
|
function switchSatView(view) {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
if (view === "history") renderSatHistoryTable();
|
||||||
if (leavingPredictions) clearPredictionDom();
|
|
||||||
if (view === "history") {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
} else if (view === "predictions") {
|
|
||||||
satPredShowAll = false;
|
|
||||||
void loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
switchSatView("live");
|
switchSatView("live");
|
||||||
});
|
});
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
switchSatView("history");
|
switchSatView("history");
|
||||||
});
|
});
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => {
|
|
||||||
switchSatView("predictions");
|
|
||||||
});
|
|
||||||
var lastSatLrptOn = null;
|
var lastSatLrptOn = null;
|
||||||
satWindow.updateSatLiveState = function(update) {
|
satWindow.updateSatLiveState = function(update) {
|
||||||
if (!satDom.lrptState) return;
|
if (!satDom.lrptState) return;
|
||||||
@@ -275,200 +239,6 @@ document.getElementById("settings-clear-sat-history")?.addEventListener("click",
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function azToCardinal(deg) {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
function formatPredTime(ms) {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = /* @__PURE__ */ new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
function formatPredDuration(s) {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
function formatCountdown(ms) {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
function elevationClass(deg) {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (satPredCountdownTimer) {
|
|
||||||
clearInterval(satPredCountdownTimer);
|
|
||||||
satPredCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function startCountdownTimer(container) {
|
|
||||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
satPredCountdownTimer = setInterval(() => {
|
|
||||||
if (satActiveView !== "predictions") {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
}, 1e3);
|
|
||||||
}
|
|
||||||
function buildCurrentPassRow(pass, now) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function buildUpcomingPassRow(pass) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function getFilteredPredictions() {
|
|
||||||
let items = satPredData;
|
|
||||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
|
||||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
|
||||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
function applyPredFilters() {
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
var satPredictionFilter = satDom.predFilter;
|
|
||||||
satPredictionFilter?.addEventListener("input", () => {
|
|
||||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
var satPredictionMinElevation = satDom.predMinEl;
|
|
||||||
satPredictionMinElevation?.addEventListener("change", () => {
|
|
||||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
var satPredictionCategory = satDom.predCategory;
|
|
||||||
satPredictionCategory?.addEventListener("change", () => {
|
|
||||||
satPredCategory = satPredictionCategory.value;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
function renderSatPredictions(passes, error) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (error) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predCurrentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
satDom.predCurrentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
satDom.predCurrentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predUpcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
satPredShowAll = true;
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
satDom.predUpcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
if (satDom.predStatus) {
|
|
||||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
|
||||||
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
|
||||||
satDom.predStatus.textContent = text;
|
|
||||||
}
|
|
||||||
if (current.length > 0 && satActiveView === "predictions") {
|
|
||||||
startCountdownTimer(satDom.predCurrentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function loadSatPredictions() {
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
satPredSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
satPredData = [];
|
|
||||||
renderSatPredictions([], data.error);
|
|
||||||
} else {
|
|
||||||
satPredData = data.passes || [];
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
satWindow.satShowOnMap = function(south, west, north, east) {
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
satWindow.enableMapSourceFilter("sat");
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
|||||||
+228
@@ -0,0 +1,228 @@
|
|||||||
|
// src/plugins/satellite-predictions.ts
|
||||||
|
var predWindow = window;
|
||||||
|
var dom = {
|
||||||
|
page: document.getElementById("tab-satellites"),
|
||||||
|
filter: document.getElementById("sat-pred-filter"),
|
||||||
|
minElevation: document.getElementById("sat-pred-min-el"),
|
||||||
|
category: document.getElementById("sat-pred-category"),
|
||||||
|
currentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
upcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
currentSection: document.getElementById("sat-pred-current-section"),
|
||||||
|
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
status: document.getElementById("sat-pred-status")
|
||||||
|
};
|
||||||
|
var PAGE_SIZE = 50;
|
||||||
|
var predShowAll = false;
|
||||||
|
var predData = [];
|
||||||
|
var predFilterText = "";
|
||||||
|
var predMinEl = 0;
|
||||||
|
var predCategory = "all";
|
||||||
|
var predSatCount = 0;
|
||||||
|
var predCountdownTimer = null;
|
||||||
|
function isPageVisible() {
|
||||||
|
return !!dom.page && dom.page.style.display !== "none";
|
||||||
|
}
|
||||||
|
function azToCardinal(deg) {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
function formatPredTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = /* @__PURE__ */ new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
function formatPredDuration(s) {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
function formatCountdown(ms) {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function elevationClass(deg) {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (predCountdownTimer) {
|
||||||
|
clearInterval(predCountdownTimer);
|
||||||
|
predCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startCountdownTimer(container) {
|
||||||
|
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
predCountdownTimer = setInterval(() => {
|
||||||
|
if (!isPageVisible()) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
}, 1e3);
|
||||||
|
}
|
||||||
|
function buildCurrentPassRow(pass, now) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function buildUpcomingPassRow(pass) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function filtered() {
|
||||||
|
let items = predData;
|
||||||
|
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
||||||
|
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
||||||
|
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function applyFilters() {
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
var filterInput = dom.filter;
|
||||||
|
filterInput?.addEventListener("input", () => {
|
||||||
|
predFilterText = filterInput.value.trim().toUpperCase();
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
var minElevationSelect = dom.minElevation;
|
||||||
|
minElevationSelect?.addEventListener("change", () => {
|
||||||
|
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
var categorySelect = dom.category;
|
||||||
|
categorySelect?.addEventListener("change", () => {
|
||||||
|
predCategory = categorySelect.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
function render(passes, error) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (error) {
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||||
|
if (dom.status) dom.status.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||||
|
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (dom.currentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
dom.currentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
dom.currentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (dom.upcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
predShowAll = true;
|
||||||
|
render(filtered());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
dom.upcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
if (dom.status) {
|
||||||
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||||
|
if (predSatCount > 0) text += ` · ${predSatCount} satellites tracked`;
|
||||||
|
dom.status.textContent = text;
|
||||||
|
}
|
||||||
|
if (current.length > 0 && isPageVisible()) {
|
||||||
|
startCountdownTimer(dom.currentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function load() {
|
||||||
|
if (dom.status) dom.status.textContent = "Loading predictions…";
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
predSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
predData = [];
|
||||||
|
render([], data.error);
|
||||||
|
} else {
|
||||||
|
predData = data.passes || [];
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
predWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
predWindow.refreshSatPredictions = function() {
|
||||||
|
predShowAll = false;
|
||||||
|
void load();
|
||||||
|
};
|
||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -111,6 +115,7 @@ function paintRow(line, rgb) {
|
|||||||
liveRows = Math.max(liveRows, line + 1);
|
liveRows = Math.max(liveRows, line + 1);
|
||||||
}
|
}
|
||||||
function onProgress(msg) {
|
function onProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (msg.state) {
|
if (msg.state) {
|
||||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
beginPicture(msg.mode || "", msg.width || 0, msg.height || 0);
|
||||||
@@ -131,15 +136,20 @@ function onImage(msg) {
|
|||||||
sstvHistory.push(image);
|
sstvHistory.push(image);
|
||||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
pruneHistory();
|
pruneHistory();
|
||||||
|
if (!isActiveRigDecode(image.rig_id)) return;
|
||||||
if (sstvDom.status) {
|
if (sstvDom.status) {
|
||||||
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
sstvDom.status.textContent = image.complete ? `Received ${image.mode ?? "picture"}` : `Partial ${image.mode ?? "picture"} — ${image.lines ?? 0} lines`;
|
||||||
}
|
}
|
||||||
scheduleUi("sstv-latest", renderLatestCard);
|
scheduleUi("sstv-latest", renderLatestCard);
|
||||||
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
if (activeView === "history") scheduleUi("sstv-history", renderHistoryTable);
|
||||||
}
|
}
|
||||||
|
function sstvRigImages() {
|
||||||
|
return forActiveRig(sstvHistory);
|
||||||
|
}
|
||||||
function renderLatestCard() {
|
function renderLatestCard() {
|
||||||
if (!sstvDom.liveLatest) return;
|
if (!sstvDom.liveLatest) return;
|
||||||
const latest = sstvHistory[sstvHistory.length - 1];
|
const rigImages = sstvRigImages();
|
||||||
|
const latest = rigImages[rigImages.length - 1];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
sstvDom.liveLatest.innerHTML = "";
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
@@ -160,8 +170,9 @@ function renderLatestCard() {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
function filteredHistory() {
|
function filteredHistory() {
|
||||||
|
const rigImages = sstvRigImages();
|
||||||
const text = filterText.trim().toLowerCase();
|
const text = filterText.trim().toLowerCase();
|
||||||
const matching = text ? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : sstvHistory.slice();
|
const matching = text ? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text)) : rigImages;
|
||||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
return matching;
|
return matching;
|
||||||
@@ -241,7 +252,11 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onImage,
|
onMessage: onImage,
|
||||||
restore: restoreHistory,
|
restore: restoreHistory,
|
||||||
prune: renderHistoryTable,
|
prune: renderHistoryTable,
|
||||||
reset: resetHistoryView
|
reset: resetHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderLatestCard();
|
||||||
|
renderHistoryTable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "sstv_progress",
|
id: "sstv_progress",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -60,17 +64,21 @@ function vdesHexPreview(rawBytes) {
|
|||||||
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
return rawBytes.slice(0, 20).map((value) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
}
|
}
|
||||||
|
function vdesRigMessages() {
|
||||||
|
return forActiveRig(vdesMessageHistory);
|
||||||
|
}
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = vdesMessageHistory.length;
|
const count = rigMessages.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = vdesMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +145,7 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id)).slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -182,7 +190,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (const message of vdesRigMessages()) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -209,10 +217,10 @@ function normalizeServerVdesMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerVdesBatch(messages) {
|
function onServerVdesBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -251,8 +259,8 @@ function plotVdesMessage(msg) {
|
|||||||
vdesWindow.vdesMapAddPoint(msg);
|
vdesWindow.vdesMapAddPoint(msg);
|
||||||
}
|
}
|
||||||
function onServerVdes(msg) {
|
function onServerVdes(msg) {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
plotVdesMessage(next);
|
plotVdesMessage(next);
|
||||||
}
|
}
|
||||||
@@ -269,6 +277,10 @@ window.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
},
|
||||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => {
|
syncMap: () => {
|
||||||
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry);
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -102,13 +106,17 @@ function paintLine(lineBytes) {
|
|||||||
wefaxLiveCtx.putImageData(imgData, 0, y);
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
wefaxLiveLineCount++;
|
wefaxLiveLineCount++;
|
||||||
}
|
}
|
||||||
|
function wefaxRigImages() {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
if (wefaxImageHistory.length === 0) {
|
const rigImages = wefaxRigImages();
|
||||||
|
if (rigImages.length === 0) {
|
||||||
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
wefaxDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const img = wefaxImageHistory[0];
|
const img = rigImages[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || "--";
|
const ts = img._ts || "--";
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
@@ -129,7 +137,7 @@ function renderWefaxLatestCard() {
|
|||||||
wefaxDom.liveLatest.innerHTML = html;
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
}
|
}
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
let items = wefaxRigImages();
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function(i) {
|
items = items.filter(function(i) {
|
||||||
const haystack = [
|
const haystack = [
|
||||||
@@ -173,7 +181,7 @@ function renderWefaxHistoryTable() {
|
|||||||
}
|
}
|
||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
const total = wefaxRigImages().length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||||
}
|
}
|
||||||
@@ -208,6 +216,7 @@ function addWefaxImage(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onServerWefaxProgress(msg) {
|
function onServerWefaxProgress(msg) {
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = msg.state;
|
wefaxDom.status.textContent = msg.state;
|
||||||
@@ -234,6 +243,7 @@ function onServerWefaxProgress(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWefax(msg) {
|
function onServerWefax(msg) {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
@@ -328,7 +338,11 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerWefax,
|
onMessage: onServerWefax,
|
||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => {
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import {
|
||||||
|
forActiveRig,
|
||||||
|
isActiveRigDecode
|
||||||
|
} from "./chunk-S57W63QN.js";
|
||||||
import {
|
import {
|
||||||
hostCore
|
hostCore
|
||||||
} from "./chunk-KL66PICH.js";
|
} from "./chunk-KL66PICH.js";
|
||||||
@@ -64,10 +68,10 @@ function renderWsprRow(msg) {
|
|||||||
function renderWsprHistory() {
|
function renderWsprHistory() {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (const message of rigMessages) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(message));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -90,6 +94,8 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -101,16 +107,11 @@ function normalizeServerWsprMessage(msg) {
|
|||||||
}
|
}
|
||||||
function onServerWsprBatch(messages) {
|
function onServerWsprBatch(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
plotWsprLocator(msg);
|
||||||
...msg,
|
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -250,15 +251,19 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerWspr(msg) {
|
function plotWsprLocator(msg) {
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
const rfHz = finiteNumber(msg._rfHz) ?? next.rfHz;
|
||||||
...msg,
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
...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);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
@@ -267,5 +272,10 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onBatch: onServerWsprBatch,
|
onBatch: onServerWsprBatch,
|
||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView
|
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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
<symbol id="icon-bookmark" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2h8v12l-4-2.5L4 14V2z"/></symbol>
|
||||||
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
<symbol id="icon-digital" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M1 11.5h2.6V4.5h3.2v7h3.2v-7h3.2v7H15"/></symbol>
|
||||||
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-map" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2a4 4 0 0 1 4 4c0 3-4 8-4 8S4 9 4 6a4 4 0 0 1 4-4z"/><circle cx="8" cy="6" r="1.2" fill="currentColor" stroke="none"/></symbol>
|
||||||
|
<symbol id="icon-satellite" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 13.2 5.6 9.6"/><path d="M4.2 8.2a3.4 3.4 0 0 1 4.8 4.8z"/><path d="M9.2 6.6a3.2 3.2 0 0 1 2.6 2.6"/><path d="M9.8 3.6a6.2 6.2 0 0 1 5.2 5.2"/></symbol>
|
||||||
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
<symbol id="icon-stats" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M2 14h12"/><rect x="3" y="8" width="2" height="6" rx="0.4" fill="currentColor" stroke="none" opacity="0.6"/><rect x="7" y="5" width="2" height="9" rx="0.4" fill="currentColor" stroke="none" opacity="0.75"/><rect x="11" y="2" width="2" height="12" rx="0.4" fill="currentColor" stroke="none" opacity="0.9"/></symbol>
|
||||||
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
<symbol id="icon-record" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><circle cx="8" cy="8" r="2.5" fill="currentColor" stroke="none"/></symbol>
|
||||||
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
<symbol id="icon-settings" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M9.8 3.1a2.6 2.6 0 0 0-2.2 3.9L3.4 11.2a1.2 1.2 0 1 0 1.7 1.7l4.2-4.2a2.6 2.6 0 0 0 3.9-2.2l-1.8.6-1.2-1.2z"/><path d="M10.2 5.8 12 4"/></symbol>
|
||||||
@@ -63,6 +64,10 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-map"/></svg>
|
||||||
<span class="tab-label">Map</span>
|
<span class="tab-label">Map</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="tab" data-tab="satellites">
|
||||||
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-satellite"/></svg>
|
||||||
|
<span class="tab-label">Satellites</span>
|
||||||
|
</button>
|
||||||
<button class="tab" data-tab="statistics">
|
<button class="tab" data-tab="statistics">
|
||||||
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
<svg class="tab-icon" aria-hidden="true"><use href="#icon-stats"/></svg>
|
||||||
<span class="tab-label">Statistics</span>
|
<span class="tab-label">Statistics</span>
|
||||||
@@ -918,7 +923,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div class="sat-view-bar">
|
<div class="sat-view-bar">
|
||||||
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
<button id="sat-view-live" class="sat-view-btn sat-view-active" type="button">Live</button>
|
||||||
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
<button id="sat-view-history" class="sat-view-btn" type="button">History</button>
|
||||||
<button id="sat-view-predictions" class="sat-view-btn" type="button">Predictions</button>
|
|
||||||
</div>
|
</div>
|
||||||
<!-- Live view -->
|
<!-- Live view -->
|
||||||
<div id="sat-live-view">
|
<div id="sat-live-view">
|
||||||
@@ -960,50 +964,6 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
<div id="sat-history-list"></div>
|
<div id="sat-history-list"></div>
|
||||||
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
<small id="sat-history-count" style="color:var(--text-muted);font-size:0.75rem;">No images yet</small>
|
||||||
</div>
|
</div>
|
||||||
<!-- Predictions view -->
|
|
||||||
<div id="sat-predictions-view" style="display:none;">
|
|
||||||
<div class="ft8-controls">
|
|
||||||
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
|
||||||
<select id="sat-pred-category" class="sat-sort-select">
|
|
||||||
<option value="all">All</option>
|
|
||||||
<option value="weather">Weather</option>
|
|
||||||
<option value="amateur">Ham Radio</option>
|
|
||||||
<option value="other">Other</option>
|
|
||||||
</select>
|
|
||||||
<select id="sat-pred-min-el" class="sat-sort-select">
|
|
||||||
<option value="0">All passes</option>
|
|
||||||
<option value="10">Min 10°</option>
|
|
||||||
<option value="20">Min 20°</option>
|
|
||||||
<option value="45">Min 45°</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<!-- Current passes -->
|
|
||||||
<div id="sat-pred-current-section">
|
|
||||||
<div class="sat-pred-section-title">Currently receivable</div>
|
|
||||||
<div class="sat-pred-header sat-pred-header-current">
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-time">AOS Start</span>
|
|
||||||
<span class="sat-pred-col-time">AOS End</span>
|
|
||||||
<span class="sat-pred-col-countdown">Time left</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-current-list"></div>
|
|
||||||
</div>
|
|
||||||
<!-- Upcoming passes -->
|
|
||||||
<div id="sat-pred-upcoming-section">
|
|
||||||
<div class="sat-pred-section-title">Upcoming passes</div>
|
|
||||||
<div class="sat-pred-header">
|
|
||||||
<span class="sat-pred-col-time">AOS (UTC)</span>
|
|
||||||
<span class="sat-pred-col-sat">Satellite</span>
|
|
||||||
<span class="sat-pred-col-el">Max El</span>
|
|
||||||
<span class="sat-pred-col-dur">Duration</span>
|
|
||||||
<span class="sat-pred-col-dir">Direction</span>
|
|
||||||
</div>
|
|
||||||
<div id="sat-pred-list"></div>
|
|
||||||
</div>
|
|
||||||
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
<div id="subtab-wefax" class="sub-tab-panel" style="display:none;">
|
||||||
<div class="ft8-controls">
|
<div class="ft8-controls">
|
||||||
@@ -1117,6 +1077,51 @@ SPDX-License-Identifier: GPL-2.0-or-later
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="tab-satellites" class="tab-panel" style="display:none;">
|
||||||
|
<h2 class="section-heading">Satellites</h2>
|
||||||
|
<div class="ft8-controls">
|
||||||
|
<input id="sat-pred-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. ISS, NOAA, Meteor)" />
|
||||||
|
<select id="sat-pred-category" class="sat-sort-select">
|
||||||
|
<option value="all">All</option>
|
||||||
|
<option value="weather">Weather</option>
|
||||||
|
<option value="amateur">Ham Radio</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
<select id="sat-pred-min-el" class="sat-sort-select">
|
||||||
|
<option value="0">All passes</option>
|
||||||
|
<option value="10">Min 10°</option>
|
||||||
|
<option value="20">Min 20°</option>
|
||||||
|
<option value="45">Min 45°</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<!-- Current passes -->
|
||||||
|
<div id="sat-pred-current-section">
|
||||||
|
<div class="sat-pred-section-title">Currently receivable</div>
|
||||||
|
<div class="sat-pred-header sat-pred-header-current">
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-time">AOS Start</span>
|
||||||
|
<span class="sat-pred-col-time">AOS End</span>
|
||||||
|
<span class="sat-pred-col-countdown">Time left</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-current-list"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Upcoming passes -->
|
||||||
|
<div id="sat-pred-upcoming-section">
|
||||||
|
<div class="sat-pred-section-title">Upcoming passes</div>
|
||||||
|
<div class="sat-pred-header">
|
||||||
|
<span class="sat-pred-col-time">AOS (UTC)</span>
|
||||||
|
<span class="sat-pred-col-sat">Satellite</span>
|
||||||
|
<span class="sat-pred-col-el">Max El</span>
|
||||||
|
<span class="sat-pred-col-dur">Duration</span>
|
||||||
|
<span class="sat-pred-col-dir">Direction</span>
|
||||||
|
</div>
|
||||||
|
<div id="sat-pred-list"></div>
|
||||||
|
</div>
|
||||||
|
<small id="sat-pred-status" style="color:var(--text-muted);font-size:0.75rem;">Loading predictions…</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
<div id="tab-statistics" class="tab-panel" style="display:none;">
|
||||||
<h2 class="section-heading">Statistics</h2>
|
<h2 class="section-heading">Statistics</h2>
|
||||||
<template id="tmpl-statistics">
|
<template id="tmpl-statistics">
|
||||||
|
|||||||
@@ -3816,11 +3816,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
body[data-operator-layout="broadcast"] #wfm-controls-col { flex-basis: 100%; }
|
||||||
}
|
}
|
||||||
/* One navigation model at every width. Statistics, Recorder, Settings and
|
/* One navigation model at every width. Satellites, Statistics, Recorder,
|
||||||
* About are occasional destinations: they live behind More rather than
|
* Settings and About are occasional destinations: they live behind More rather
|
||||||
* competing with the operating tabs for the row and then scrolling out of
|
* than competing with the operating tabs for the row and then scrolling out of
|
||||||
* reach. The mobile layout already grouped them this way; the desktop strip
|
* reach. The mobile layout already grouped them this way; the desktop strip
|
||||||
* now matches it, which is why the tab strip no longer needs to scroll. */
|
* now matches it, which is why the tab strip no longer needs to scroll. */
|
||||||
|
.tab-bar-nav .tab[data-tab="satellites"],
|
||||||
.tab-bar-nav .tab[data-tab="statistics"],
|
.tab-bar-nav .tab[data-tab="statistics"],
|
||||||
.tab-bar-nav .tab[data-tab="recorder"],
|
.tab-bar-nav .tab[data-tab="recorder"],
|
||||||
.tab-bar-nav .tab[data-tab="settings"],
|
.tab-bar-nav .tab[data-tab="settings"],
|
||||||
@@ -4030,8 +4031,9 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay {
|
|||||||
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
box-shadow: inset 0 1px 0 color-mix(in srgb, #ffffff 8%, transparent);
|
||||||
}
|
}
|
||||||
.tab-icon, .tab-more-icon { display: block; }
|
.tab-icon, .tab-more-icon { display: block; }
|
||||||
.tab[data-tab="statistics"], .tab[data-tab="recorder"],
|
.tab[data-tab="satellites"], .tab[data-tab="statistics"],
|
||||||
.tab[data-tab="settings"], .tab[data-tab="about"] { display: none; }
|
.tab[data-tab="recorder"], .tab[data-tab="settings"],
|
||||||
|
.tab[data-tab="about"] { display: none; }
|
||||||
.mobile-more-btn { display: flex; }
|
.mobile-more-btn { display: flex; }
|
||||||
.mobile-more-menu {
|
.mobile-more-menu {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ await build({
|
|||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.ts"),
|
||||||
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
sat: path.join(sourceDir, "plugins", "sat.ts"),
|
||||||
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.ts"),
|
||||||
|
"satellite-predictions": path.join(sourceDir, "plugins", "satellite-predictions.ts"),
|
||||||
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
vchan: path.join(sourceDir, "plugins", "vchan.ts"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.ts"),
|
||||||
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
scheduler: path.join(sourceDir, "plugins", "scheduler.ts"),
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
"typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json",
|
||||||
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"test": "node --test tests/*.test.mjs",
|
||||||
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs",
|
"test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs && node tests/decode-flow.mjs && node tests/tune-links.mjs && node tests/mobile-layout.mjs && node tests/satellite-predictions.mjs",
|
||||||
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
"verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -385,12 +385,14 @@ declare global {
|
|||||||
refreshCwTonePicker?(): void;
|
refreshCwTonePicker?(): void;
|
||||||
updateFt8RfDisplay?(): void;
|
updateFt8RfDisplay?(): void;
|
||||||
clearSatPredictionDom?(): void;
|
clearSatPredictionDom?(): void;
|
||||||
|
refreshSatPredictions?(): void;
|
||||||
syncWefaxToggle?(enabled: boolean): void;
|
syncWefaxToggle?(enabled: boolean): void;
|
||||||
syncSstvToggle?(enabled: boolean): void;
|
syncSstvToggle?(enabled: boolean): void;
|
||||||
updateAisBar?(value?: number): void;
|
updateAisBar?(value?: number): void;
|
||||||
updateVdesBar?(value?: number): void;
|
updateVdesBar?(value?: number): void;
|
||||||
updateAprsBar?(value?: number): void;
|
updateAprsBar?(value?: number): void;
|
||||||
updateFt8Bar?(value?: number): void;
|
updateFt8Bar?(value?: number): void;
|
||||||
|
updateCwBar?(value?: number): void;
|
||||||
updateSatLiveState?(value: unknown): void;
|
updateSatLiveState?(value: unknown): void;
|
||||||
applyCwAutoUi?(enabled: boolean): void;
|
applyCwAutoUi?(enabled: boolean): void;
|
||||||
applyCwAutoUiFromServer?(enabled: boolean): void;
|
applyCwAutoUiFromServer?(enabled: boolean): void;
|
||||||
@@ -2206,6 +2208,17 @@ function resetRdsDisplay() {
|
|||||||
updateRdsPsOverlay(primaryRds);
|
updateRdsPsOverlay(primaryRds);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The mini views over the waterfall show only what the rig on screen heard, so
|
||||||
|
// they have to be repainted whenever that rig — or its mode — changes, not just
|
||||||
|
// when the next decode happens to arrive.
|
||||||
|
function refreshDecodeBars() {
|
||||||
|
window.updateAisBar?.();
|
||||||
|
window.updateVdesBar?.();
|
||||||
|
window.updateAprsBar?.();
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
window.updateCwBar?.();
|
||||||
|
}
|
||||||
|
|
||||||
function resetDecoderStateOnRigSwitch() {
|
function resetDecoderStateOnRigSwitch() {
|
||||||
// RDS
|
// RDS
|
||||||
primaryRds = null;
|
primaryRds = null;
|
||||||
@@ -2225,6 +2238,12 @@ function resetDecoderStateOnRigSwitch() {
|
|||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = "--";
|
if (el) el.textContent = "--";
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The decode stream is not rig-scoped, so the histories behind the mini views
|
||||||
|
// and the decoder panels survive the switch: repaint both for the rig now
|
||||||
|
// selected instead of leaving the outgoing rig's traffic on screen.
|
||||||
|
refreshDecodeBars();
|
||||||
|
window.trxPluginRuntime.rerenderAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetWfmStereoIndicator() {
|
function resetWfmStereoIndicator() {
|
||||||
@@ -3675,10 +3694,7 @@ function render(update: AppUpdate) {
|
|||||||
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
const connText = _decodeConnectedText[d.id] || "Connected, listening for packets";
|
||||||
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
setModeBoundDecodeStatus(el, d.active_modes, "Select " + d.active_modes[0] + " mode to decode", connText);
|
||||||
}
|
}
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
refreshDecodeBars();
|
||||||
if (window.updateVdesBar) window.updateVdesBar();
|
|
||||||
if (window.updateAprsBar) window.updateAprsBar();
|
|
||||||
if (window.updateFt8Bar) window.updateFt8Bar();
|
|
||||||
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
// Toggle-gated decoder status: clear "Receiving" when decoder disabled or mode wrong.
|
||||||
for (const d of decoderRegistry) {
|
for (const d of decoderRegistry) {
|
||||||
if (d.activation !== "toggle") continue;
|
if (d.activation !== "toggle") continue;
|
||||||
@@ -4849,6 +4865,7 @@ function _initMapWhenReady() {
|
|||||||
|
|
||||||
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
function navigateToTab(name: TabName, options: { updateHistory?: boolean; replaceHistory?: boolean } = {}) {
|
||||||
window.trxUi?.closeMobileOverlays?.();
|
window.trxUi?.closeMobileOverlays?.();
|
||||||
|
const leavingSatellites = _activeTab === "satellites" && name !== "satellites";
|
||||||
const { updateHistory = true, replaceHistory = false } = options;
|
const { updateHistory = true, replaceHistory = false } = options;
|
||||||
if (authEnabled && !authRole && name !== "main") {
|
if (authEnabled && !authRole && name !== "main") {
|
||||||
showAuthGate(false);
|
showAuthGate(false);
|
||||||
@@ -4889,7 +4906,15 @@ function navigateToTab(name: TabName, options: { updateHistory?: boolean; replac
|
|||||||
updateTabHistory(name, replaceHistory);
|
updateTabHistory(name, replaceHistory);
|
||||||
}
|
}
|
||||||
scheduleSpectrumLayout();
|
scheduleSpectrumLayout();
|
||||||
void loadPluginsForTab(name).catch((error: unknown) => { console.error(error); });
|
// Its countdowns tick every second, so the page stops when it is not on screen.
|
||||||
|
if (leavingSatellites) window.clearSatPredictionDom?.();
|
||||||
|
void loadPluginsForTab(name).then(() => {
|
||||||
|
// Passes go stale while the page is closed, so each visit reloads them.
|
||||||
|
// The first visit is what imports the module, which renders on its own.
|
||||||
|
if (name === "satellites") window.refreshSatPredictions?.();
|
||||||
|
// The map module owns the decode log; this is the moment it can exist.
|
||||||
|
flushPendingDecodeStats();
|
||||||
|
}).catch((error: unknown) => { console.error(error); });
|
||||||
if (name === "map") {
|
if (name === "map") {
|
||||||
_initMapWhenReady();
|
_initMapWhenReady();
|
||||||
}
|
}
|
||||||
@@ -5241,10 +5266,6 @@ function _wireSubTabBar(bar: WiredElement) {
|
|||||||
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
if (window.refreshCwTonePicker) window.refreshCwTonePicker();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Clear SAT prediction DOM when leaving the SAT tab to reduce node count.
|
|
||||||
if (btn.dataset.subtab !== "sat" && typeof window.clearSatPredictionDom === "function") {
|
|
||||||
window.clearSatPredictionDom();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
document.querySelectorAll<WiredElement>(".sub-tab-bar").forEach(_wireSubTabBar);
|
||||||
@@ -6578,11 +6599,49 @@ const IMAGE_DECODE_KINDS = new Set([
|
|||||||
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
"lrpt_image", "lrpt_progress", "wefax", "wefax_progress", "sstv", "sstv_progress",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// The decode log the Statistics page counts lives in the map module, which is
|
||||||
|
// lazy: it arrives when the Map or Statistics tab is first opened, long after
|
||||||
|
// the history replayed and the live decodes started coming. Recording into a
|
||||||
|
// module that is not there yet dropped every one of them, and nothing replayed
|
||||||
|
// them afterwards -- so the page opened empty and only filled in from decodes
|
||||||
|
// heard after it, which is why it took a reload (landing on the tab, so the
|
||||||
|
// module loads at startup) to show the whole picture. Hold them here until
|
||||||
|
// the module arrives, then hand them over.
|
||||||
|
interface PendingDecodeStat { kind: string; rig: string | null; tsMs: number | undefined }
|
||||||
|
const pendingDecodeStats: PendingDecodeStat[] = [];
|
||||||
|
// The module's own log keeps 50k entries; there is no point holding more here.
|
||||||
|
const PENDING_DECODE_STATS_MAX = 50_000;
|
||||||
|
|
||||||
|
function recordDecodeStat(kind: string, rig: string | null, tsMs?: number) {
|
||||||
|
const stats = window.trx.modules.map;
|
||||||
|
if (stats) {
|
||||||
|
stats.statsRecordDecode(kind, rig, tsMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingDecodeStats.push({ kind, rig, tsMs });
|
||||||
|
if (pendingDecodeStats.length > PENDING_DECODE_STATS_MAX) {
|
||||||
|
pendingDecodeStats.splice(0, pendingDecodeStats.length - PENDING_DECODE_STATS_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushPendingDecodeStats() {
|
||||||
|
const stats = window.trx.modules.map;
|
||||||
|
if (!stats || pendingDecodeStats.length === 0) return;
|
||||||
|
for (const entry of pendingDecodeStats.splice(0)) {
|
||||||
|
stats.statsRecordDecode(entry.kind, entry.rig, entry.tsMs);
|
||||||
|
}
|
||||||
|
stats.scheduleStatsRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleStatsRenderIfLoaded() {
|
||||||
|
window.trx.modules.map?.scheduleStatsRender();
|
||||||
|
}
|
||||||
|
|
||||||
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
function dispatchDecodeMessage(msg: DecodeMessage, skipStats = false) {
|
||||||
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
if (msg.type) window.trxPluginRuntime.dispatch(msg.type, msg);
|
||||||
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
if (!skipStats && msg.type && !IMAGE_DECODE_KINDS.has(msg.type)) {
|
||||||
window.trx.modules.map?.statsRecordDecode(msg.type, msg.rig_id || msg.remote || null);
|
recordDecodeStat(msg.type, msg.rig_id || msg.remote || null);
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -6632,9 +6691,9 @@ function restoreDecodeHistoryGroup(kind: string, messages: DecodeMessage[]) {
|
|||||||
// Record statistics for restored history messages.
|
// Record statistics for restored history messages.
|
||||||
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
if (!IMAGE_DECODE_KINDS.has(kind)) {
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
window.trx.modules.map?.statsRecordDecode(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
recordDecodeStat(kind, msg.rig_id || msg.remote || null, msg.ts_ms || undefined);
|
||||||
}
|
}
|
||||||
window.trx.modules.map?.scheduleStatsRender();
|
scheduleStatsRenderIfLoaded();
|
||||||
}
|
}
|
||||||
window.trxPluginRuntime.restore(kind, messages);
|
window.trxPluginRuntime.restore(kind, messages);
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-1
@@ -3,7 +3,7 @@
|
|||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
export const TAB_ORDER = [
|
export const TAB_ORDER = [
|
||||||
"main", "bookmarks", "digital-modes", "map", "statistics", "recorder", "settings", "about",
|
"main", "bookmarks", "digital-modes", "map", "satellites", "statistics", "recorder", "settings", "about",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type TabName = typeof TAB_ORDER[number];
|
export type TabName = typeof TAB_ORDER[number];
|
||||||
@@ -13,6 +13,7 @@ export const TAB_PATHS: Readonly<Record<TabName, string>> = {
|
|||||||
bookmarks: "/bookmarks",
|
bookmarks: "/bookmarks",
|
||||||
"digital-modes": "/digital-modes",
|
"digital-modes": "/digital-modes",
|
||||||
map: "/map",
|
map: "/map",
|
||||||
|
satellites: "/satellites",
|
||||||
statistics: "/statistics",
|
statistics: "/statistics",
|
||||||
recorder: "/recorder",
|
recorder: "/recorder",
|
||||||
settings: "/settings",
|
settings: "/settings",
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ function mapEl(id: string): MapElement {
|
|||||||
return element as MapElement;
|
return element as MapElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DecoderSource = "ais" | "vdes" | "aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
type DecoderSource = "ais" | "vdes" | "aprs" | "hf_aprs" | "bookmark" | "ft8" | "ft4" | "ft2" | "wspr" | "sat";
|
||||||
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
interface TrackPoint { lat: number; lon: number; tsMs: number }
|
||||||
interface DecodeDetail {
|
interface DecodeDetail {
|
||||||
station?: string | null; source?: string | null; target?: string | null;
|
station?: string | null; source?: string | null; target?: string | null;
|
||||||
@@ -83,6 +83,9 @@ interface MapEntry {
|
|||||||
bookmarks?: Array<Record<string, unknown>>;
|
bookmarks?: Array<Record<string, unknown>>;
|
||||||
bounds?: Leaflet.LatLngBoundsExpression;
|
bounds?: Leaflet.LatLngBoundsExpression;
|
||||||
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
symbolTable?: string; symbolCode?: string; bandLabel?: string | null;
|
||||||
|
/** Callsign as heard. The map key qualifies it with the source, so that a
|
||||||
|
* station worked on both 144 MHz and 30 m keeps one marker per band. */
|
||||||
|
call?: string;
|
||||||
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
overlay?: TrxLayer | null; line?: TrxLayer | null; labelMarker?: TrxLayer | null;
|
||||||
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
pathKey?: string; sourceGrid?: string; targetGrid?: string;
|
||||||
from?: LatLon; to?: LatLon;
|
from?: LatLon; to?: LatLon;
|
||||||
@@ -171,7 +174,7 @@ interface MapWindow {
|
|||||||
clearMapMarkersByType?(type: DecoderSource): void;
|
clearMapMarkersByType?(type: DecoderSource): void;
|
||||||
navigateToAprsMap?(lat: number, lon: number): void;
|
navigateToAprsMap?(lat: number, lon: number): void;
|
||||||
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
navigateToMapLocator?(grid: string, preferredType?: string | null): void;
|
||||||
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage): void;
|
aprsMapAddStation?(call: string, lat: number, lon: number, info: string, symbolTable: string, symbolCode: string, packet: MapMessage, source?: "aprs" | "hf_aprs"): void;
|
||||||
aisMapAddVessel?(message: MapMessage): void;
|
aisMapAddVessel?(message: MapMessage): void;
|
||||||
vdesMapAddPoint?(message: MapMessage): void;
|
vdesMapAddPoint?(message: MapMessage): void;
|
||||||
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
syncBookmarkMapLocators?(bookmarks: Array<Record<string, unknown>>): void;
|
||||||
@@ -228,7 +231,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
const decodeContactPaths = new Map<string, MapEntry>();
|
const decodeContactPaths = new Map<string, MapEntry>();
|
||||||
let selectedMapQsoKey: string | null = null;
|
let selectedMapQsoKey: string | null = null;
|
||||||
const mapMarkers = new Set<TrxLayer>();
|
const mapMarkers = new Set<TrxLayer>();
|
||||||
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
const DEFAULT_MAP_SOURCE_FILTER: Record<MapFilterKey, boolean> = { ais: true, vdes: true, aprs: true, hf_aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false };
|
||||||
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
const mapFilter: Record<MapFilterKey, boolean> = { ...DEFAULT_MAP_SOURCE_FILTER };
|
||||||
/** Chip key that clears a selection rather than naming a band or a source. */
|
/** Chip key that clears a selection rather than naming a band or a source. */
|
||||||
const MAP_FILTER_ALL_KEY = "__all";
|
const MAP_FILTER_ALL_KEY = "__all";
|
||||||
@@ -302,6 +305,18 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The source a station marker belongs to, defaulting to VHF APRS for
|
||||||
|
* entries stored before HF APRS had a source of its own. */
|
||||||
|
function aprsEntrySource(entry: MapEntry | null | undefined): DecoderSource {
|
||||||
|
return entry?.type === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map key for a station: the callsign alone on VHF, source-qualified on HF,
|
||||||
|
* so one station heard on both bands keeps a marker for each. */
|
||||||
|
function aprsStationKey(call: string, source: DecoderSource): string {
|
||||||
|
return source === "aprs" ? call : `${source}:${call}`;
|
||||||
|
}
|
||||||
|
|
||||||
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
function refreshAprsTrack(call: string, entry: MapEntry): void {
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
|
||||||
@@ -323,7 +338,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
lineJoin: "round",
|
lineJoin: "round",
|
||||||
interactive: false,
|
interactive: false,
|
||||||
}) as unknown as TrxLayer;
|
}) as unknown as TrxLayer;
|
||||||
track.__trxType = "aprs";
|
track.__trxType = aprsEntrySource(entry);
|
||||||
track._aprsCall = call;
|
track._aprsCall = call;
|
||||||
entry.track = track;
|
entry.track = track;
|
||||||
}
|
}
|
||||||
@@ -575,6 +590,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
|
|
||||||
function mapSourceLabel(type: DecoderSource): string {
|
function mapSourceLabel(type: DecoderSource): string {
|
||||||
if (type === "bookmark") return "Bookmarks";
|
if (type === "bookmark") return "Bookmarks";
|
||||||
|
if (type === "hf_aprs") return "HF APRS";
|
||||||
return String(type || "").toUpperCase();
|
return String(type || "").toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,6 +610,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (type === "vdes") return "#a78bfa";
|
if (type === "vdes") return "#a78bfa";
|
||||||
if (type === "sat") return "#f59e0b";
|
if (type === "sat") return "#f59e0b";
|
||||||
if (type === "aprs") return "#00d17f";
|
if (type === "aprs") return "#00d17f";
|
||||||
|
// Far enough from the VHF green to tell the two apart at a glance.
|
||||||
|
if (type === "hf_aprs") return "#fb7185";
|
||||||
return locatorFilterColor(type);
|
return locatorFilterColor(type);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1188,10 +1206,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
if (!entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add("aprs");
|
availableSources.add(aprsEntrySource(entry));
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const bandMap = new Map<string, FilterChip>();
|
const bandMap = new Map<string, FilterChip>();
|
||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
@@ -1225,7 +1241,7 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
const sourceItems: FilterChip[] = (["ais", "vdes", "aprs", "hf_aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"] as DecoderSource[])
|
||||||
.filter((key) => availableSources.has(key))
|
.filter((key) => availableSources.has(key))
|
||||||
.map((key) => ({
|
.map((key) => ({
|
||||||
key,
|
key,
|
||||||
@@ -1296,9 +1312,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
}
|
}
|
||||||
return parts.join(" ").toLowerCase();
|
return parts.join(" ").toLowerCase();
|
||||||
}
|
}
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
const call = marker?._aprsCall ? String(marker._aprsCall) : "";
|
const key = marker?._aprsCall ? String(marker._aprsCall) : "";
|
||||||
const entry = stationMarkers.get(call);
|
const entry = stationMarkers.get(key);
|
||||||
|
const call = entry?.call ?? key;
|
||||||
const info = entry?.info ? String(entry.info) : "";
|
const info = entry?.info ? String(entry.info) : "";
|
||||||
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : "";
|
||||||
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
return `${call} ${info} ${pktRaw}`.toLowerCase();
|
||||||
@@ -1496,9 +1513,10 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
};
|
};
|
||||||
|
|
||||||
mapWindow.clearMapMarkersByType = function(type) {
|
mapWindow.clearMapMarkersByType = function(type) {
|
||||||
if (type === "aprs") {
|
if (type === "aprs" || type === "hf_aprs") {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
stationMarkers.forEach((entry) => {
|
stationMarkers.forEach((entry, key) => {
|
||||||
|
if (aprsEntrySource(entry) !== type) return;
|
||||||
if (entry && entry.marker) {
|
if (entry && entry.marker) {
|
||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
@@ -1507,8 +1525,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.track);
|
mapMarkers.delete(entry.track);
|
||||||
}
|
}
|
||||||
|
stationMarkers.delete(key);
|
||||||
});
|
});
|
||||||
stationMarkers.clear();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1781,13 +1799,15 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
if (!ll) return;
|
if (!ll) return;
|
||||||
const entry = stationMarkers.get(marker._aprsCall);
|
const entry = stationMarkers.get(marker._aprsCall);
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? String(marker._aprsCall);
|
||||||
|
e.popup.setContent(buildAprsPopupHtml(call, ll.lat, ll.lng, entry.info || "", entry.pkt));
|
||||||
refreshAprsTrack(String(marker._aprsCall), entry);
|
refreshAprsTrack(String(marker._aprsCall), entry);
|
||||||
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
|
if (entry.track && aprsMap && mapFilter[source] && !aprsMap.hasLayer(entry.track)) {
|
||||||
entry.track.addTo(aprsMap);
|
entry.track.addTo(aprsMap);
|
||||||
}
|
}
|
||||||
selectedAprsTrackCall = String(marker._aprsCall);
|
selectedAprsTrackCall = String(marker._aprsCall);
|
||||||
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds);
|
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor(source), "aprs-radio-path", marker.__trxRigIds);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2270,28 +2290,33 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function _aprsAddMarkerToMap(call: string, entry: MapEntry): void {
|
function _aprsAddMarkerToMap(key: string, entry: MapEntry): void {
|
||||||
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
if (!aprsMap || entry.lat == null || entry.lon == null) return;
|
||||||
refreshAprsTrack(call, entry);
|
refreshAprsTrack(key, entry);
|
||||||
|
const source = aprsEntrySource(entry);
|
||||||
|
const call = entry.call ?? key;
|
||||||
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? "");
|
||||||
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
|
||||||
|
const color = mapSourceColor(source);
|
||||||
const marker = (icon
|
const marker = (icon
|
||||||
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent)
|
||||||
: L.circleMarker([entry.lat, entry.lon], {
|
: L.circleMarker([entry.lat, entry.lon], {
|
||||||
radius: 6, color: "#00d17f", fillColor: "#00d17f", fillOpacity: 0.8
|
radius: 6, color, fillColor: color, fillOpacity: 0.8
|
||||||
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
}).addTo(aprsMap).bindPopup(popupContent)) as unknown as TrxLayer;
|
||||||
marker.__trxType = "aprs";
|
marker.__trxType = source;
|
||||||
marker.__trxRigIds = entry.rigIds || new Set();
|
marker.__trxRigIds = entry.rigIds || new Set();
|
||||||
marker._aprsCall = call;
|
marker._aprsCall = key;
|
||||||
entry.marker = marker;
|
entry.marker = marker;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
|
mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt, source) {
|
||||||
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
const nextPoint: Leaflet.LatLngTuple = [lat, lon];
|
||||||
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
|
||||||
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
const msgRigId = pkt?.rig_id || T.lastActiveRigId;
|
||||||
const existing = stationMarkers.get(call);
|
const stationSource: DecoderSource = source === "hf_aprs" ? "hf_aprs" : "aprs";
|
||||||
|
const key = aprsStationKey(call, stationSource);
|
||||||
|
const existing = stationMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.pkt = pkt;
|
existing.pkt = pkt;
|
||||||
existing.lat = lat;
|
existing.lat = lat;
|
||||||
@@ -2310,7 +2335,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
} else if (prevPoint) {
|
} else if (prevPoint) {
|
||||||
prevPoint.tsMs = tsMs;
|
prevPoint.tsMs = tsMs;
|
||||||
}
|
}
|
||||||
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
|
existing.call = call;
|
||||||
|
pruneAprsEntry(key, existing, mapHistoryCutoffMs());
|
||||||
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) {
|
||||||
existing.marker.setLatLng([lat, lon]);
|
existing.marker.setLatLng([lat, lon]);
|
||||||
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
|
||||||
@@ -2321,7 +2347,8 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat, lon, tsMs }],
|
trackHistory: [{ lat, lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
type: "aprs",
|
type: stationSource,
|
||||||
|
call,
|
||||||
pkt,
|
pkt,
|
||||||
lat,
|
lat,
|
||||||
lon,
|
lon,
|
||||||
@@ -2330,9 +2357,9 @@ const mapWindow = window as unknown as MapWindow;
|
|||||||
symbolCode,
|
symbolCode,
|
||||||
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
rigIds: new Set(msgRigId ? [msgRigId] : []),
|
||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(key, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(key, entry, mapHistoryCutoffMs());
|
||||||
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(key, entry);
|
||||||
if (aprsMap) scheduleDecodeMapMaintenance();
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
type PluginGroup = "digital-modes" | "map-data" | "map" | "statistics" | "bookmarks" | "recorder" | "settings";
|
type PluginGroup =
|
||||||
|
| "digital-modes" | "map-data" | "map" | "satellites" | "statistics"
|
||||||
|
| "bookmarks" | "recorder" | "settings";
|
||||||
|
|
||||||
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
||||||
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
// AIS, VDES and the two APRS decoders have panels on this tab, so they load
|
||||||
@@ -15,6 +17,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|||||||
],
|
],
|
||||||
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
"map-data": ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js"],
|
||||||
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
map: ["/map-core.js", "/ais.js", "/vdes.js", "/aprs.js", "/hf-aprs.js", "/sat.js", "/sat-scheduler.js"],
|
||||||
|
satellites: ["/satellite-predictions.js"],
|
||||||
statistics: ["/map-core.js"],
|
statistics: ["/map-core.js"],
|
||||||
bookmarks: ["/bookmarks.js"],
|
bookmarks: ["/bookmarks.js"],
|
||||||
recorder: [],
|
recorder: [],
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const runtime: TrxPluginRuntime = {
|
|||||||
plugin.prune();
|
plugin.prune();
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
rerenderAll() { for (const plugin of decoders.values()) plugin.rerender?.(); },
|
||||||
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
syncMapAll() { for (const plugin of decoders.values()) plugin.syncMap?.(); },
|
||||||
clearQueued() { queued.clear(); },
|
clearQueued() { queued.clear(); },
|
||||||
hasDecoder: (id) => decoders.has(id),
|
hasDecoder: (id) => decoders.has(id),
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { hostState } from "./host.js";
|
||||||
|
|
||||||
|
// The decode stream is not rig-scoped: every rig a client is connected to sends
|
||||||
|
// its decodes to every browser, each frame naming the rig that heard it (the
|
||||||
|
// client stamps `rig_id` as it leaves the audio connection). The map wants all
|
||||||
|
// of them — it has its own filter, and plotting only one rig would empty it —
|
||||||
|
// but the radio page and the decoder panels describe one rig at a time: the one
|
||||||
|
// the operator selected, whose spectrum is on screen and whose audio is
|
||||||
|
// playing. A frame a background rig copied on another band belongs to neither
|
||||||
|
// picture, so both filter on this.
|
||||||
|
export function isActiveRigDecode(rigId: string | null | undefined): boolean {
|
||||||
|
const activeRigId = hostState.lastActiveRigId;
|
||||||
|
// Before the rig list has arrived, and for a decode that reached the browser
|
||||||
|
// without a rig of its own, there is nothing to disagree with.
|
||||||
|
if (!activeRigId || !rigId) return true;
|
||||||
|
return rigId === activeRigId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The decodes of `items` that the selected rig heard, in their original order. */
|
||||||
|
export function forActiveRig<T extends { rig_id?: string | null | undefined }>(items: T[]): T[] {
|
||||||
|
return items.filter((item) => isActiveRigDecode(item.rig_id));
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { hostCore, hostState } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface AisMessage {
|
interface AisMessage {
|
||||||
@@ -190,20 +191,27 @@ function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
|||||||
return Array.from(byMmsi.values());
|
return Array.from(byMmsi.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function aisRigMessages(): AisMessage[] {
|
||||||
|
return forActiveRig(aisMessageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function updateAisSummary() {
|
function updateAisSummary() {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
if (aisChannelSummaryEl) {
|
if (aisChannelSummaryEl) {
|
||||||
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vessels = aisLatestByVessel(aisMessageHistory);
|
const rigMessages = aisRigMessages();
|
||||||
|
const vessels = aisLatestByVessel(rigMessages);
|
||||||
if (aisVesselCountEl) {
|
if (aisVesselCountEl) {
|
||||||
const count = vessels.length;
|
const count = vessels.length;
|
||||||
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (aisLatestSeenEl) {
|
if (aisLatestSeenEl) {
|
||||||
const latest = aisMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aisLatestSeenEl.textContent = "No traffic yet";
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -314,7 +322,9 @@ function updateAisBar() {
|
|||||||
|
|
||||||
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
const recent = aisMessageHistory.filter(
|
||||||
|
(msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id),
|
||||||
|
);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -368,7 +378,7 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of aisMessageHistory) {
|
for (const message of aisRigMessages()) {
|
||||||
fragment.appendChild(renderAisRow(message));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
@@ -407,10 +417,11 @@ function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
|||||||
|
|
||||||
function onServerAisBatch(messages: AisMessage[]): void {
|
function onServerAisBatch(messages: AisMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
||||||
const normalized: AisMessage[] = [];
|
const normalized: AisMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -452,8 +463,9 @@ if (aisFilterInput) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerAis(msg: AisMessage): void {
|
function onServerAis(msg: AisMessage): void {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
const message = normalizeServerAisMessage(msg);
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
@@ -464,6 +476,7 @@ updateAisSummary();
|
|||||||
restore: onServerAisBatch,
|
restore: onServerAisBatch,
|
||||||
reset: resetAisHistoryView,
|
reset: resetAisHistoryView,
|
||||||
prune: pruneAisHistoryView,
|
prune: pruneAisHistoryView,
|
||||||
|
rerender: () => { updateAisBar(); renderAisHistory(); },
|
||||||
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
syncMap: () => { for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
@@ -106,21 +107,29 @@ function aprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(aprsFilterText);
|
return haystack.includes(aprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function aprsRigPackets(): AprsPacket[] {
|
||||||
|
return forActiveRig(aprsPacketHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function aprsVisiblePackets(): AprsPacket[] {
|
function aprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
const rigPackets = aprsRigPackets();
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(aprsFilterMatch);
|
return packets.filter(aprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateAprsSummary() {
|
function updateAprsSummary() {
|
||||||
|
const rigPackets = aprsRigPackets();
|
||||||
const visible = aprsVisiblePackets();
|
const visible = aprsVisiblePackets();
|
||||||
if (aprsTotalCountEl) {
|
if (aprsTotalCountEl) {
|
||||||
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
aprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (aprsVisibleCountEl) {
|
if (aprsVisibleCountEl) {
|
||||||
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (aprsLatestSeenEl) {
|
if (aprsLatestSeenEl) {
|
||||||
const latest = aprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
aprsLatestSeenEl.textContent = "No packets yet";
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -179,7 +188,9 @@ function updateAprsBar() {
|
|||||||
if (!aprsBarOverlay) return;
|
if (!aprsBarOverlay) return;
|
||||||
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
const isPkt = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "PKT";
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
const okFrames = aprsPacketHistory.filter(
|
||||||
|
(p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(p.rig_id),
|
||||||
|
);
|
||||||
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
@@ -256,11 +267,12 @@ function normalizeServerAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerAprsBatch(packets: AprsPacket[]): void {
|
function onServerAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
let hasCrcOk = false;
|
let hasCrcOk = false;
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerAprsPacket(pkt);
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (aprsStatus && isActiveRigDecode(next.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
@@ -324,8 +336,9 @@ if (aprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side APRS decode handler ---
|
// --- Server-side APRS decode handler ---
|
||||||
function onServerAprs(pkt: AprsPacket): void {
|
function onServerAprs(pkt: AprsPacket): void {
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
const packet = normalizeServerAprsPacket(pkt);
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
if (aprsStatus && isActiveRigDecode(packet.rig_id)) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
@@ -336,6 +349,7 @@ renderAprsHistory();
|
|||||||
restore: onServerAprsBatch,
|
restore: onServerAprsBatch,
|
||||||
reset: resetAprsHistoryView,
|
reset: resetAprsHistoryView,
|
||||||
prune: pruneAprsHistoryView,
|
prune: pruneAprsHistoryView,
|
||||||
|
rerender: () => { updateAprsBar(); renderAprsHistory(); },
|
||||||
// Oldest first, so station tracks are rebuilt in the order they happened.
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
syncMap: () => { for (const entry of [...aprsPacketHistory].reverse()) plotAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -20,8 +21,11 @@ interface CwRenderer {
|
|||||||
drawPoints(points: number[], size: number, color: Rgba): void;
|
drawPoints(points: number[], size: number, color: Rgba): void;
|
||||||
}
|
}
|
||||||
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
interface CwSpectrum { bins: number[]; sample_rate: number; center_hz: number }
|
||||||
interface CwEvent { text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
interface CwEvent { rig_id?: string | null; text?: string; wpm?: number; tone_hz?: number; signal_on?: boolean }
|
||||||
interface CwLine { tsMs: number; ts: string; text: string; wpm: number | null; tone_hz: number | null; lastMs: number }
|
interface CwLine {
|
||||||
|
rigId: string | null; tsMs: number; ts: string; text: string;
|
||||||
|
wpm: number | null; tone_hz: number | null; lastMs: number;
|
||||||
|
}
|
||||||
interface CwToneRange {
|
interface CwToneRange {
|
||||||
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
tunedHz: number; bandwidthHz: number; toneMinHz: number; toneMaxHz: number;
|
||||||
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
toneSpanHz: number; lowerSideband: boolean; mode: string;
|
||||||
@@ -66,7 +70,10 @@ const CW_BAR_LINE_GAP_MS = 5000;
|
|||||||
let cwLastAppendTime = 0;
|
let cwLastAppendTime = 0;
|
||||||
let cwTonePickerRaf: number | null = null;
|
let cwTonePickerRaf: number | null = null;
|
||||||
let cwBarHistory: CwLine[] = [];
|
let cwBarHistory: CwLine[] = [];
|
||||||
let cwBarCurrentLine: CwLine | null = null;
|
// One line in progress per rig, keyed by rig id ("" for a decode that names no
|
||||||
|
// rig). Two rigs copying at the same time each fill their own line instead of
|
||||||
|
// braiding their characters into one.
|
||||||
|
const cwBarCurrentLines = new Map<string, CwLine>();
|
||||||
let cwBarDismissedAtMs = 0;
|
let cwBarDismissedAtMs = 0;
|
||||||
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
// acknowledged). While set, server-state updates must not override the
|
// acknowledged). While set, server-state updates must not override the
|
||||||
@@ -101,12 +108,13 @@ cwWindow.applyCwAutoUiFromServer = function(enabled: boolean) {
|
|||||||
applyCwAutoUi(enabled);
|
applyCwAutoUi(enabled);
|
||||||
};
|
};
|
||||||
|
|
||||||
function cwBarFlushCurrentLine(): void {
|
function cwBarFlushCurrentLine(key: string): void {
|
||||||
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
const line = cwBarCurrentLines.get(key);
|
||||||
cwBarHistory.unshift(cwBarCurrentLine);
|
cwBarCurrentLines.delete(key);
|
||||||
|
if (line?.text.trim()) {
|
||||||
|
cwBarHistory.unshift(line);
|
||||||
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
cwBarCurrentLine = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateCwBar(): void {
|
function updateCwBar(): void {
|
||||||
@@ -114,9 +122,12 @@ function updateCwBar(): void {
|
|||||||
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
const mode = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase();
|
||||||
const isCw = mode === "CW" || mode === "CWR";
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs && isActiveRigDecode(l.rigId));
|
||||||
// Prepend the in-progress line so characters appear immediately
|
// Prepend the in-progress lines so characters appear immediately
|
||||||
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
const inProgress = [...cwBarCurrentLines.values()]
|
||||||
|
.filter((l) => l.text && isActiveRigDecode(l.rigId))
|
||||||
|
.sort((a, b) => b.tsMs - a.tsMs);
|
||||||
|
const liveLines = [...inProgress, ...recent];
|
||||||
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, line.tsMs || 0), 0);
|
||||||
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
cwBarOverlay.style.display = "none";
|
cwBarOverlay.style.display = "none";
|
||||||
@@ -402,7 +413,7 @@ function resetCwHistoryView(): void {
|
|||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
cwLastAppendTime = 0;
|
cwLastAppendTime = 0;
|
||||||
cwBarHistory = [];
|
cwBarHistory = [];
|
||||||
cwBarCurrentLine = null;
|
cwBarCurrentLines.clear();
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
}
|
}
|
||||||
@@ -421,8 +432,13 @@ document.getElementById("settings-clear-cw-history")?.addEventListener("click",
|
|||||||
|
|
||||||
// --- Server-side CW decode handler ---
|
// --- Server-side CW decode handler ---
|
||||||
function onServerCw(evt: CwEvent): void {
|
function onServerCw(evt: CwEvent): void {
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
// The panel is a single stream of copied text: characters from a background
|
||||||
if (evt.text && cwOutputEl) {
|
// rig do not belong in it at all, and interleaving them would make both
|
||||||
|
// rigs unreadable. The bar histories below keep every rig, each on its own
|
||||||
|
// line, and pick out the selected rig when they paint.
|
||||||
|
const forSelectedRig = isActiveRigDecode(evt.rig_id ?? null);
|
||||||
|
if (cwStatusEl && forSelectedRig) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl && forSelectedRig) {
|
||||||
// Append decoded text to output
|
// Append decoded text to output
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||||
@@ -445,25 +461,29 @@ function onServerCw(evt: CwEvent): void {
|
|||||||
// Bar history accumulation (regardless of pause state)
|
// Bar history accumulation (regardless of pause state)
|
||||||
if (evt.text) {
|
if (evt.text) {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const rigId = evt.rig_id ?? null;
|
||||||
|
const key = rigId ?? "";
|
||||||
if (evt.text === "\n") {
|
if (evt.text === "\n") {
|
||||||
cwBarFlushCurrentLine();
|
cwBarFlushCurrentLine(key);
|
||||||
} else {
|
} else {
|
||||||
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
let line = cwBarCurrentLines.get(key);
|
||||||
cwBarFlushCurrentLine();
|
if (!line || now - line.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine(key);
|
||||||
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
const ts = new Date(now).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
cwBarCurrentLine = { tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
line = { rigId, tsMs: now, ts, text: "", wpm: null, tone_hz: null, lastMs: now };
|
||||||
|
cwBarCurrentLines.set(key, line);
|
||||||
}
|
}
|
||||||
cwBarCurrentLine.text += evt.text;
|
line.text += evt.text;
|
||||||
cwBarCurrentLine.lastMs = now;
|
line.lastMs = now;
|
||||||
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
if (Number.isFinite(Number(evt.wpm))) line.wpm = clampCwWpm(evt.wpm);
|
||||||
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
if (Number.isFinite(Number(evt.tone_hz))) line.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
}
|
}
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
}
|
}
|
||||||
if (cwSignalIndicator) {
|
if (cwSignalIndicator && forSelectedRig) {
|
||||||
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
}
|
}
|
||||||
if (!cwAutoInput || cwAutoInput.checked) {
|
if (forSelectedRig && (!cwAutoInput || cwAutoInput.checked)) {
|
||||||
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
cwWpmInput.value = String(clampCwWpm(evt.wpm));
|
||||||
}
|
}
|
||||||
@@ -491,6 +511,13 @@ cwWindow.trxPluginRuntime.registerDecoder({
|
|||||||
onMessage: onServerCw,
|
onMessage: onServerCw,
|
||||||
restore: restoreCwHistory,
|
restore: restoreCwHistory,
|
||||||
reset: resetCwHistoryView,
|
reset: resetCwHistoryView,
|
||||||
|
// The copied text of a rig that is no longer on screen cannot be unpicked
|
||||||
|
// from the pane, so the switch starts the new rig's stream from empty.
|
||||||
|
rerender: () => {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
updateCwBar();
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
export type FtxDecoderId = "ft2" | "ft4" | "ft8";
|
||||||
@@ -15,6 +16,7 @@ export interface FtxMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
|
rig_id?: string | null | undefined;
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -227,9 +229,12 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
const render = (): void => {
|
const render = (): void => {
|
||||||
prune();
|
prune();
|
||||||
if (!messagesElement) return;
|
if (!messagesElement) return;
|
||||||
|
// The history holds every rig's decodes, for the map; the panel is about
|
||||||
|
// the rig the operator is working.
|
||||||
|
const rigMessages = forActiveRig(history);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const message of history) {
|
for (const message of rigMessages) {
|
||||||
if (count >= 200) break;
|
if (count >= 200) break;
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
fragment.appendChild(renderRow(message));
|
fragment.appendChild(renderRow(message));
|
||||||
@@ -237,20 +242,32 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
}
|
}
|
||||||
messagesElement.replaceChildren(fragment);
|
messagesElement.replaceChildren(fragment);
|
||||||
};
|
};
|
||||||
const normalize = (message: FtxMessage): FtxMessage => {
|
/** Hands a decode's grid squares to the map, if the map module is loaded yet.
|
||||||
|
* Split out of normalize so a decode can be replayed onto a map that
|
||||||
|
* arrived later: the module is lazy, and everything decoded before it
|
||||||
|
* loaded had nowhere to go. */
|
||||||
|
const plotLocator = (message: FtxMessage): void => {
|
||||||
const raw = message.message ?? "";
|
const raw = message.message ?? "";
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
const grids = locatorDetails.length > 0
|
const grids = locatorDetails.length > 0
|
||||||
? locatorDetails.map(({ grid }) => grid)
|
? locatorDetails.map(({ grid }) => grid)
|
||||||
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
: bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
if (grids.length === 0) return;
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
// Already an RF frequency on a replay, an audio offset on arrival; the
|
||||||
|
// conversion only fires below 100 kHz, so it is right either way.
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
if (grids.length > 0) {
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
||||||
...message, freq_hz: frequency ?? message.freq_hz, locator_details: locatorDetails,
|
});
|
||||||
});
|
};
|
||||||
}
|
const normalize = (message: FtxMessage): FtxMessage => {
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
plotLocator(message);
|
||||||
return {
|
return {
|
||||||
|
// The rig that heard it, kept so the mini view can tell a decode of the
|
||||||
|
// rig on screen from one a background rig made on another band.
|
||||||
|
rig_id: message.rig_id ?? null,
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
ts_ms: message.ts_ms,
|
ts_ms: message.ts_ms,
|
||||||
snr_db: message.snr_db,
|
snr_db: message.snr_db,
|
||||||
@@ -262,7 +279,10 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const receiveBatch = (messages: FtxMessage[]): void => {
|
const receiveBatch = (messages: FtxMessage[]): void => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
if (status) status.textContent = "Receiving";
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (status && messages.some((message) => isActiveRigDecode(message.rig_id))) {
|
||||||
|
status.textContent = "Receiving";
|
||||||
|
}
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
prune();
|
prune();
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
@@ -277,7 +297,8 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
};
|
};
|
||||||
const barFrames = (): BarFrames => {
|
const barFrames = (): BarFrames => {
|
||||||
const recent = history
|
const recent = history
|
||||||
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000)
|
.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 900_000
|
||||||
|
&& isActiveRigDecode(message.rig_id))
|
||||||
.slice(0, 8);
|
.slice(0, 8);
|
||||||
let html = "";
|
let html = "";
|
||||||
for (const message of recent) {
|
for (const message of recent) {
|
||||||
@@ -299,6 +320,9 @@ export function initializeFtxDecoder(config: FtxConfig): void {
|
|||||||
restore: receiveBatch,
|
restore: receiveBatch,
|
||||||
prune: () => { prune(); render(); },
|
prune: () => { prune(); render(); },
|
||||||
reset,
|
reset,
|
||||||
|
rerender: () => { bridge.updateFt8Bar?.(); render(); },
|
||||||
|
// Oldest first, so the map builds the grids up in the order they were heard.
|
||||||
|
syncMap: () => { for (const message of [...history].reverse()) plotLocator(message); },
|
||||||
});
|
});
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore, hostState } from "./host.js";
|
import { hostCore, hostState } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import {
|
import {
|
||||||
aprsAgeText,
|
aprsAgeText,
|
||||||
aprsPacketCategory,
|
aprsPacketCategory,
|
||||||
@@ -19,6 +20,12 @@ interface HfAprsBridge {
|
|||||||
getDecodeHistoryRetentionMs?: () => number;
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
navigateToAprsMap?: (lat: number, lon: number) => void;
|
navigateToAprsMap?: (lat: number, lon: number) => void;
|
||||||
|
aprsMapAddStation?: (
|
||||||
|
call: string, lat: number, lon: number, info: string,
|
||||||
|
symbolTable: string | null | undefined, symbolCode: string | null | undefined,
|
||||||
|
packet: AprsPacket, source: "aprs" | "hf_aprs",
|
||||||
|
) => void;
|
||||||
|
clearMapMarkersByType?: (type: string) => void;
|
||||||
getDecodeRigMeta?: () => unknown;
|
getDecodeRigMeta?: () => unknown;
|
||||||
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
takeSchedulerControlForDecoderDisable?: (button: HTMLElement) => Promise<unknown>;
|
||||||
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
@@ -90,23 +97,31 @@ function hfAprsFilterMatch(pkt: AprsPacket): boolean {
|
|||||||
return haystack.includes(hfAprsFilterText);
|
return haystack.includes(hfAprsFilterText);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function hfAprsRigPackets(): AprsPacket[] {
|
||||||
|
return forActiveRig(hfAprsPacketHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function hfAprsVisiblePackets(): AprsPacket[] {
|
function hfAprsVisiblePackets(): AprsPacket[] {
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
const rigPackets = hfAprsRigPackets();
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(rigPackets) : rigPackets;
|
||||||
return packets.filter(hfAprsFilterMatch);
|
return packets.filter(hfAprsFilterMatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
const collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
|
||||||
function updateHfAprsSummary() {
|
function updateHfAprsSummary() {
|
||||||
|
const rigPackets = hfAprsRigPackets();
|
||||||
const visible = hfAprsVisiblePackets();
|
const visible = hfAprsVisiblePackets();
|
||||||
if (hfAprsTotalCountEl) {
|
if (hfAprsTotalCountEl) {
|
||||||
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
hfAprsTotalCountEl.textContent = `${rigPackets.length} total`;
|
||||||
}
|
}
|
||||||
if (hfAprsVisibleCountEl) {
|
if (hfAprsVisibleCountEl) {
|
||||||
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
}
|
}
|
||||||
if (hfAprsLatestSeenEl) {
|
if (hfAprsLatestSeenEl) {
|
||||||
const latest = hfAprsPacketHistory[0];
|
const latest = rigPackets[0];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
hfAprsLatestSeenEl.textContent = "No packets yet";
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
} else {
|
} else {
|
||||||
@@ -168,6 +183,7 @@ function resetHfAprsHistoryView(): void {
|
|||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
hfAprsPacketHistory = [];
|
hfAprsPacketHistory = [];
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
|
hfAprsWindow.clearMapMarkersByType?.("hf_aprs");
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneHfAprsHistoryView(): void {
|
function pruneHfAprsHistoryView(): void {
|
||||||
@@ -175,6 +191,17 @@ function pruneHfAprsHistoryView(): void {
|
|||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Hands a positioned packet to the map, if the map module is loaded yet.
|
||||||
|
* HF traffic goes on as its own source: it is a different band and a
|
||||||
|
* different path, and the map filter offers it separately. */
|
||||||
|
function plotHfAprsPacket(pkt: AprsPacket): void {
|
||||||
|
if (pkt.lat == null || pkt.lon == null || !hfAprsWindow.aprsMapAddStation) return;
|
||||||
|
hfAprsWindow.aprsMapAddStation(
|
||||||
|
pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "",
|
||||||
|
pkt.symbolTable, pkt.symbolCode, pkt, "hf_aprs",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function addHfAprsPacket(pkt: AprsPacket): void {
|
function addHfAprsPacket(pkt: AprsPacket): void {
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
pkt._tsMs = tsMs;
|
pkt._tsMs = tsMs;
|
||||||
@@ -183,6 +210,8 @@ function addHfAprsPacket(pkt: AprsPacket): void {
|
|||||||
hfAprsPacketHistory.unshift(pkt);
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
pruneHfAprsPacketHistory();
|
pruneHfAprsPacketHistory();
|
||||||
|
|
||||||
|
plotHfAprsPacket(pkt);
|
||||||
|
|
||||||
scheduleHfAprsHistoryRender();
|
scheduleHfAprsHistoryRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +221,15 @@ function normalizeServerHfAprsPacket(pkt: AprsPacket): AprsPacket {
|
|||||||
|
|
||||||
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
function onServerHfAprsBatch(packets: AprsPacket[]): void {
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
const normalized: AprsPacket[] = [];
|
const normalized: AprsPacket[] = [];
|
||||||
for (const pkt of packets) {
|
for (const pkt of packets) {
|
||||||
const next = normalizeServerHfAprsPacket(pkt);
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (hfAprsStatus && isActiveRigDecode(next.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
plotHfAprsPacket(next);
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
normalized.reverse();
|
||||||
@@ -266,8 +297,9 @@ if (hfAprsFilterInput) {
|
|||||||
|
|
||||||
// --- Server-side HF APRS decode handler ---
|
// --- Server-side HF APRS decode handler ---
|
||||||
function onServerHfAprs(pkt: AprsPacket): void {
|
function onServerHfAprs(pkt: AprsPacket): void {
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
const packet = normalizeServerHfAprsPacket(pkt);
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
if (hfAprsStatus && isActiveRigDecode(packet.rig_id)) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(packet);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
@@ -278,4 +310,7 @@ renderHfAprsHistory();
|
|||||||
restore: onServerHfAprsBatch,
|
restore: onServerHfAprsBatch,
|
||||||
reset: resetHfAprsHistoryView,
|
reset: resetHfAprsHistoryView,
|
||||||
prune: pruneHfAprsHistoryView,
|
prune: pruneHfAprsHistoryView,
|
||||||
|
rerender: renderHfAprsHistory,
|
||||||
|
// Oldest first, so station tracks are rebuilt in the order they happened.
|
||||||
|
syncMap: () => { for (const entry of [...hfAprsPacketHistory].reverse()) plotHfAprsPacket(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ export interface DecoderPlugin<TMessage = unknown> {
|
|||||||
restore?(messages: TMessage[]): void;
|
restore?(messages: TMessage[]): void;
|
||||||
reset?(): void;
|
reset?(): void;
|
||||||
prune?(): void;
|
prune?(): void;
|
||||||
|
/** Repaint the plugin's panel from what it already holds. Called when the
|
||||||
|
* selected rig changes: the panels show one rig's traffic, and the decodes
|
||||||
|
* behind them arrived long before the switch. */
|
||||||
|
rerender?(): void;
|
||||||
/** Replay everything the plugin is holding onto the map. Called when the map
|
/** Replay everything the plugin is holding onto the map. Called when the map
|
||||||
* module attaches, which can happen long after the decodes arrived. */
|
* module attaches, which can happen long after the decodes arrived. */
|
||||||
syncMap?(): void;
|
syncMap?(): void;
|
||||||
@@ -22,6 +26,7 @@ export interface TrxPluginRuntime {
|
|||||||
reset(id: string): boolean;
|
reset(id: string): boolean;
|
||||||
resetAll(): void;
|
resetAll(): void;
|
||||||
prune(id: string): boolean;
|
prune(id: string): boolean;
|
||||||
|
rerenderAll(): void;
|
||||||
syncMapAll(): void;
|
syncMapAll(): void;
|
||||||
clearQueued(): void;
|
clearQueued(): void;
|
||||||
hasDecoder(id: string): boolean;
|
hasDecoder(id: string): boolean;
|
||||||
|
|||||||
@@ -4,13 +4,12 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate, SatellitePass, SatellitePassResponse } from "./satellite-types";
|
import type { LrptProgress, SatelliteImage, SatelliteLiveUpdate } from "./satellite-types";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
type SatelliteView = "live" | "history" | "predictions";
|
type SatelliteView = "live" | "history";
|
||||||
interface SatelliteBridge {
|
interface SatelliteBridge {
|
||||||
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
clearSatPredictionDom?: () => void;
|
|
||||||
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
updateSatLiveState?: (update: SatelliteLiveUpdate) => void;
|
||||||
addSatMapOverlay?: (image: SatelliteImage) => void;
|
addSatMapOverlay?: (image: SatelliteImage) => void;
|
||||||
clearSatMapOverlays?: () => void;
|
clearSatMapOverlays?: () => void;
|
||||||
@@ -25,14 +24,14 @@ const satWindow = window as unknown as SatelliteBridge & PluginRuntimeWindow;
|
|||||||
// --- SAT Plugin ---
|
// --- SAT Plugin ---
|
||||||
// Live view: decoder state, latest image card
|
// Live view: decoder state, latest image card
|
||||||
// History view: filterable table of all decoded images
|
// History view: filterable table of all decoded images
|
||||||
// Predictions view: next 24 h passes for ham satellites
|
//
|
||||||
|
// Pass predictions live on their own page; see satellite-predictions.ts.
|
||||||
|
|
||||||
// ── DOM references (cached once) ───────────────────────────────────
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
const satDom = {
|
const satDom = {
|
||||||
status: document.getElementById("sat-status"),
|
status: document.getElementById("sat-status"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
liveView: document.getElementById("sat-live-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
@@ -42,30 +41,13 @@ const satDom = {
|
|||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
|
||||||
predFilter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
|
||||||
predMinEl: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
|
||||||
predCategory: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
|
||||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
|
||||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
|
||||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
|
||||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
|
||||||
predStatus: document.getElementById("sat-pred-status"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── State ───────────────────────────────────────────────────────────
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
let satImageHistory: SatelliteImage[] = [];
|
let satImageHistory: SatelliteImage[] = [];
|
||||||
const SAT_MAX_IMAGES = 100;
|
const SAT_MAX_IMAGES = 100;
|
||||||
const SAT_PRED_PAGE_SIZE = 50;
|
|
||||||
let satPredShowAll = false;
|
|
||||||
let satFilterText = "";
|
let satFilterText = "";
|
||||||
let satActiveView: SatelliteView = "live";
|
let satActiveView: SatelliteView = "live";
|
||||||
let satPredData: SatellitePass[] = [];
|
|
||||||
let satPredFilterText = "";
|
|
||||||
let satPredMinEl = 0;
|
|
||||||
let satPredCategory = "all";
|
|
||||||
let satPredSatCount = 0;
|
|
||||||
let satPredCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
// ── UI scheduler helper ─────────────────────────────────────────────
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
function scheduleSatUi(key: string, job: () => void): void {
|
function scheduleSatUi(key: string, job: () => void): void {
|
||||||
@@ -78,33 +60,16 @@ function scheduleSatUi(key: string, job: () => void): void {
|
|||||||
|
|
||||||
// ── View switching ──────────────────────────────────────────────────
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
function switchSatView(view: SatelliteView): void {
|
function switchSatView(view: SatelliteView): void {
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
|
||||||
satActiveView = view;
|
satActiveView = view;
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (view === "history") renderSatHistoryTable();
|
||||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
|
||||||
if (leavingPredictions) clearPredictionDom();
|
|
||||||
if (view === "history") {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
} else if (view === "predictions") {
|
|
||||||
satPredShowAll = false;
|
|
||||||
void loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
satDom.viewLiveBtn?.addEventListener("click", () => { switchSatView("live"); });
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
satDom.viewHistoryBtn?.addEventListener("click", () => { switchSatView("history"); });
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => { switchSatView("predictions"); });
|
|
||||||
|
|
||||||
// ── Live view: decoder state ────────────────────────────────────────
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
let lastSatLrptOn: boolean | null = null;
|
let lastSatLrptOn: boolean | null = null;
|
||||||
@@ -335,234 +300,6 @@ document
|
|||||||
}
|
}
|
||||||
})(); });
|
})(); });
|
||||||
|
|
||||||
// ── Predictions: helpers ────────────────────────────────────────────
|
|
||||||
function azToCardinal(deg: number): string {
|
|
||||||
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
|
||||||
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredTime(ms: number): string {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPredDuration(s: number): string {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCountdown(ms: number): string {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function elevationClass(deg: number): string {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: countdown timer management ─────────────────────────
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (satPredCountdownTimer) {
|
|
||||||
clearInterval(satPredCountdownTimer);
|
|
||||||
satPredCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startCountdownTimer(container: HTMLElement | null): void {
|
|
||||||
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
|
|
||||||
satPredCountdownTimer = setInterval(() => {
|
|
||||||
if (satActiveView !== "predictions") {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: row builders ───────────────────────────────────────
|
|
||||||
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`,
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: filter state ───────────────────────────────────────
|
|
||||||
function getFilteredPredictions(): SatellitePass[] {
|
|
||||||
let items = satPredData;
|
|
||||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
|
||||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
|
||||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPredFilters() {
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
|
|
||||||
const satPredictionFilter = satDom.predFilter;
|
|
||||||
satPredictionFilter?.addEventListener("input", () => {
|
|
||||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const satPredictionMinElevation = satDom.predMinEl;
|
|
||||||
satPredictionMinElevation?.addEventListener("change", () => {
|
|
||||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
const satPredictionCategory = satDom.predCategory;
|
|
||||||
satPredictionCategory?.addEventListener("change", () => {
|
|
||||||
satPredCategory = satPredictionCategory.value;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Predictions: main render ────────────────────────────────────────
|
|
||||||
function renderSatPredictions(passes: SatellitePass[], error?: string): void {
|
|
||||||
stopCountdownTimer();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
|
|
||||||
// ── Current passes ──
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predCurrentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
satDom.predCurrentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
satDom.predCurrentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Upcoming passes ──
|
|
||||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predUpcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
satPredShowAll = true;
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
satDom.predUpcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Status ──
|
|
||||||
if (satDom.predStatus) {
|
|
||||||
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
|
||||||
if (satPredSatCount > 0) text += ` \u00B7 ${satPredSatCount} satellites tracked`;
|
|
||||||
satDom.predStatus.textContent = text;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Countdown timer ──
|
|
||||||
if (current.length > 0 && satActiveView === "predictions") {
|
|
||||||
startCountdownTimer(satDom.predCurrentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Predictions: data loading ───────────────────────────────────────
|
|
||||||
async function loadSatPredictions(): Promise<void> {
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions\u2026";
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json() as SatellitePassResponse;
|
|
||||||
satPredSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
satPredData = [];
|
|
||||||
renderSatPredictions([], data.error);
|
|
||||||
} else {
|
|
||||||
satPredData = data.passes || [];
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
} catch (error: unknown) {
|
|
||||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Navigate to map centered on satellite image bounds ──────────────
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
satWindow.satShowOnMap = function (south: number, west: number, north: number, east: number) {
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
|||||||
+290
@@ -0,0 +1,290 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import type { SatellitePass, SatellitePassResponse } from "./satellite-types";
|
||||||
|
|
||||||
|
// --- Satellite pass predictions ---
|
||||||
|
//
|
||||||
|
// Its own page rather than a third view inside the weather-satellite decoder:
|
||||||
|
// when a bird comes over is a planning question, and it was buried under
|
||||||
|
// Digital modes beside decoders it has nothing to do with.
|
||||||
|
|
||||||
|
interface PredictionsBridge {
|
||||||
|
clearSatPredictionDom?: () => void;
|
||||||
|
refreshSatPredictions?: () => void;
|
||||||
|
}
|
||||||
|
const predWindow = window as unknown as PredictionsBridge;
|
||||||
|
|
||||||
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
|
const dom = {
|
||||||
|
page: document.getElementById("tab-satellites"),
|
||||||
|
filter: document.getElementById("sat-pred-filter") as HTMLInputElement | null,
|
||||||
|
minElevation: document.getElementById("sat-pred-min-el") as HTMLSelectElement | null,
|
||||||
|
category: document.getElementById("sat-pred-category") as HTMLSelectElement | null,
|
||||||
|
currentList: document.getElementById("sat-pred-current-list"),
|
||||||
|
upcomingList: document.getElementById("sat-pred-list"),
|
||||||
|
currentSection: document.getElementById("sat-pred-current-section"),
|
||||||
|
upcomingSection: document.getElementById("sat-pred-upcoming-section"),
|
||||||
|
status: document.getElementById("sat-pred-status"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
|
const PAGE_SIZE = 50;
|
||||||
|
let predShowAll = false;
|
||||||
|
let predData: SatellitePass[] = [];
|
||||||
|
let predFilterText = "";
|
||||||
|
let predMinEl = 0;
|
||||||
|
let predCategory = "all";
|
||||||
|
let predSatCount = 0;
|
||||||
|
let predCountdownTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
/** The countdowns tick once a second, so they stop when nobody is looking. */
|
||||||
|
function isPageVisible(): boolean {
|
||||||
|
return !!dom.page && dom.page.style.display !== "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: helpers ────────────────────────────────────────────
|
||||||
|
function azToCardinal(deg: number): string {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8] ?? "N";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredTime(ms: number): string {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredDuration(s: number): string {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms: number): string {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1000));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function elevationClass(deg: number): string {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: countdown timer management ─────────────────────────
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (predCountdownTimer) {
|
||||||
|
clearInterval(predCountdownTimer);
|
||||||
|
predCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCountdownTimer(container: HTMLElement | null): void {
|
||||||
|
const countdownEls = container?.querySelectorAll<HTMLElement>(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
|
||||||
|
predCountdownTimer = setInterval(() => {
|
||||||
|
if (!isPageVisible()) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: row builders ───────────────────────────────────────
|
||||||
|
function buildCurrentPassRow(pass: SatellitePass, now: number): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildUpcomingPassRow(pass: SatellitePass): HTMLElement {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} \u2192 ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}\u00B0</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`,
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: filter state ───────────────────────────────────────
|
||||||
|
function filtered(): SatellitePass[] {
|
||||||
|
let items = predData;
|
||||||
|
if (predCategory !== "all") items = items.filter((p) => p.category === predCategory);
|
||||||
|
if (predMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= predMinEl);
|
||||||
|
if (predFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(predFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFilters() {
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterInput = dom.filter;
|
||||||
|
filterInput?.addEventListener("input", () => {
|
||||||
|
predFilterText = filterInput.value.trim().toUpperCase();
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const minElevationSelect = dom.minElevation;
|
||||||
|
minElevationSelect?.addEventListener("change", () => {
|
||||||
|
predMinEl = Number.parseInt(minElevationSelect.value, 10) || 0;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
const categorySelect = dom.category;
|
||||||
|
categorySelect?.addEventListener("change", () => {
|
||||||
|
predCategory = categorySelect.value;
|
||||||
|
applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Predictions: main render ────────────────────────────────────────
|
||||||
|
function render(passes: SatellitePass[], error?: string): void {
|
||||||
|
stopCountdownTimer();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||||
|
if (dom.status) dom.status.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = "none";
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = "none";
|
||||||
|
if (dom.status) dom.status.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
|
||||||
|
// ── Current passes ──
|
||||||
|
if (dom.currentSection) dom.currentSection.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (dom.currentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
dom.currentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
dom.currentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Upcoming passes ──
|
||||||
|
const upcomingLimit = predShowAll ? upcoming.length : PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (dom.upcomingSection) dom.upcomingSection.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (dom.upcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes\u2026</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
predShowAll = true;
|
||||||
|
render(filtered());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
dom.upcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status ──
|
||||||
|
if (dom.status) {
|
||||||
|
let text = `${current.length} active \u00B7 ${upcoming.length} upcoming \u00B7 times in UTC`;
|
||||||
|
if (predSatCount > 0) text += ` \u00B7 ${predSatCount} satellites tracked`;
|
||||||
|
dom.status.textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Countdown timer ──
|
||||||
|
if (current.length > 0 && isPageVisible()) {
|
||||||
|
startCountdownTimer(dom.currentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Predictions: data loading ───────────────────────────────────────
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (dom.status) dom.status.textContent = "Loading predictions\u2026";
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json() as SatellitePassResponse;
|
||||||
|
predSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
predData = [];
|
||||||
|
render([], data.error);
|
||||||
|
} else {
|
||||||
|
predData = data.passes || [];
|
||||||
|
render(filtered());
|
||||||
|
}
|
||||||
|
} catch (error: unknown) {
|
||||||
|
render([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Page lifecycle ──────────────────────────────────────────────────
|
||||||
|
function clearPredictionDom(): void {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (dom.currentList) dom.currentList.innerHTML = "";
|
||||||
|
if (dom.upcomingList) dom.upcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
predWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called by the router each time the Satellites page is opened, including the
|
||||||
|
* visit that imports this module — so loading is driven from one place only.
|
||||||
|
*/
|
||||||
|
predWindow.refreshSatPredictions = function () {
|
||||||
|
predShowAll = false;
|
||||||
|
void load();
|
||||||
|
};
|
||||||
@@ -14,11 +14,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface SstvImage {
|
interface SstvImage {
|
||||||
|
rig_id?: string | null;
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
vis?: number;
|
vis?: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
@@ -32,6 +34,7 @@ interface SstvImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface SstvProgress {
|
interface SstvProgress {
|
||||||
|
rig_id?: string | null;
|
||||||
state?: string;
|
state?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
width?: number;
|
width?: number;
|
||||||
@@ -185,6 +188,9 @@ function paintRow(line: number, rgb: Uint8Array): void {
|
|||||||
// ── Server messages ─────────────────────────────────────────────────
|
// ── Server messages ─────────────────────────────────────────────────
|
||||||
|
|
||||||
function onProgress(msg: SstvProgress): void {
|
function onProgress(msg: SstvProgress): void {
|
||||||
|
// The live canvas draws one picture at a time: a background rig's rows would
|
||||||
|
// paint straight over the one being watched.
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
if (msg.state) {
|
if (msg.state) {
|
||||||
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
if (sstvDom.status) sstvDom.status.textContent = msg.state;
|
||||||
// A state update carries the geometry; a row update carries only the row.
|
// A state update carries the geometry; a row update carries only the row.
|
||||||
@@ -208,6 +214,9 @@ function onImage(msg: SstvImage): void {
|
|||||||
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
if (sstvHistory.length > SSTV_MAX_IMAGES) sstvHistory.shift();
|
||||||
pruneHistory();
|
pruneHistory();
|
||||||
|
|
||||||
|
// Kept in the history either way, so switching to that rig finds it there.
|
||||||
|
if (!isActiveRigDecode(image.rig_id)) return;
|
||||||
|
|
||||||
if (sstvDom.status) {
|
if (sstvDom.status) {
|
||||||
sstvDom.status.textContent = image.complete
|
sstvDom.status.textContent = image.complete
|
||||||
? `Received ${image.mode ?? "picture"}`
|
? `Received ${image.mode ?? "picture"}`
|
||||||
@@ -219,9 +228,15 @@ function onImage(msg: SstvImage): void {
|
|||||||
|
|
||||||
// ── Rendering ───────────────────────────────────────────────────────
|
// ── Rendering ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The pictures the selected rig decoded, oldest first, as the history is kept. */
|
||||||
|
function sstvRigImages(): SstvImage[] {
|
||||||
|
return forActiveRig(sstvHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function renderLatestCard(): void {
|
function renderLatestCard(): void {
|
||||||
if (!sstvDom.liveLatest) return;
|
if (!sstvDom.liveLatest) return;
|
||||||
const latest = sstvHistory[sstvHistory.length - 1];
|
const rigImages = sstvRigImages();
|
||||||
|
const latest = rigImages[rigImages.length - 1];
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
sstvDom.liveLatest.innerHTML = "";
|
sstvDom.liveLatest.innerHTML = "";
|
||||||
return;
|
return;
|
||||||
@@ -245,10 +260,12 @@ function renderLatestCard(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filteredHistory(): SstvImage[] {
|
function filteredHistory(): SstvImage[] {
|
||||||
|
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||||
|
const rigImages = sstvRigImages();
|
||||||
const text = filterText.trim().toLowerCase();
|
const text = filterText.trim().toLowerCase();
|
||||||
const matching = text
|
const matching = text
|
||||||
? sstvHistory.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
? rigImages.filter((image) => (image.mode ?? "").toLowerCase().includes(text))
|
||||||
: sstvHistory.slice();
|
: rigImages;
|
||||||
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
const newestFirst = (sstvDom.sortSelect?.value ?? "newest") === "newest";
|
||||||
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
matching.sort((a, b) => (newestFirst ? 1 : -1) * ((b._tsMs ?? 0) - (a._tsMs ?? 0)));
|
||||||
return matching;
|
return matching;
|
||||||
@@ -348,6 +365,7 @@ sstvWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreHistory,
|
restore: restoreHistory,
|
||||||
prune: renderHistoryTable,
|
prune: renderHistoryTable,
|
||||||
reset: resetHistoryView,
|
reset: resetHistoryView,
|
||||||
|
rerender: () => { renderLatestCard(); renderHistoryTable(); },
|
||||||
});
|
});
|
||||||
sstvWindow.trxPluginRuntime.registerDecoder({
|
sstvWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "sstv_progress",
|
id: "sstv_progress",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { hostCore } from "./host.js";
|
|||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract";
|
import type { PluginRuntimeWindow } from "./runtime-contract";
|
||||||
|
|
||||||
interface VdesMessage {
|
interface VdesMessage {
|
||||||
@@ -116,17 +117,24 @@ function vdesHexPreview(rawBytes: number[] | undefined): string {
|
|||||||
.toUpperCase();
|
.toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What the selected rig heard. The history holds every rig's traffic, for
|
||||||
|
* the map; the panel is about the rig the operator is working. */
|
||||||
|
function vdesRigMessages(): VdesMessage[] {
|
||||||
|
return forActiveRig(vdesMessageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function updateVdesSummary() {
|
function updateVdesSummary() {
|
||||||
pruneVdesMessageHistory();
|
pruneVdesMessageHistory();
|
||||||
if (vdesChannelSummaryEl) {
|
if (vdesChannelSummaryEl) {
|
||||||
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
|
const rigMessages = vdesRigMessages();
|
||||||
if (vdesFrameCountEl) {
|
if (vdesFrameCountEl) {
|
||||||
const count = vdesMessageHistory.length;
|
const count = rigMessages.length;
|
||||||
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
}
|
}
|
||||||
if (vdesLatestSeenEl) {
|
if (vdesLatestSeenEl) {
|
||||||
const latest = vdesMessageHistory[0];
|
const latest = rigMessages[0];
|
||||||
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -228,7 +236,9 @@ function updateVdesBar() {
|
|||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
const isVdes = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "VDES";
|
||||||
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
const messages = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs).slice(0, 6);
|
const messages = vdesMessageHistory
|
||||||
|
.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs && isActiveRigDecode(msg.rig_id))
|
||||||
|
.slice(0, 6);
|
||||||
if (!isVdes || messages.length === 0) {
|
if (!isVdes || messages.length === 0) {
|
||||||
vdesBarOverlay.style.display = "none";
|
vdesBarOverlay.style.display = "none";
|
||||||
vdesBarOverlay.innerHTML = "";
|
vdesBarOverlay.innerHTML = "";
|
||||||
@@ -278,7 +288,7 @@ function renderVdesHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (const message of vdesMessageHistory) {
|
for (const message of vdesRigMessages()) {
|
||||||
fragment.appendChild(renderVdesRow(message));
|
fragment.appendChild(renderVdesRow(message));
|
||||||
}
|
}
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
@@ -309,10 +319,11 @@ function normalizeServerVdesMessage(msg: VdesMessage): VdesMessage {
|
|||||||
|
|
||||||
function onServerVdesBatch(messages: VdesMessage[]): void {
|
function onServerVdesBatch(messages: VdesMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const normalized: VdesMessage[] = [];
|
const normalized: VdesMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
next._tsMs = tsMs;
|
next._tsMs = tsMs;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -354,8 +365,8 @@ function plotVdesMessage(msg: VdesMessage): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onServerVdes(msg: VdesMessage): void {
|
function onServerVdes(msg: VdesMessage): void {
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
if (vdesStatus && isActiveRigDecode(next.rig_id)) vdesStatus.textContent = "Receiving";
|
||||||
addVdesMessage(next);
|
addVdesMessage(next);
|
||||||
plotVdesMessage(next);
|
plotVdesMessage(next);
|
||||||
}
|
}
|
||||||
@@ -374,6 +385,7 @@ updateVdesSummary();
|
|||||||
restore: onServerVdesBatch,
|
restore: onServerVdesBatch,
|
||||||
reset: resetVdesHistoryView,
|
reset: resetVdesHistoryView,
|
||||||
prune: pruneVdesHistoryView,
|
prune: pruneVdesHistoryView,
|
||||||
|
rerender: () => { updateVdesBar(); renderVdesHistory(); },
|
||||||
// Oldest first, so tracks are rebuilt in the order they happened.
|
// Oldest first, so tracks are rebuilt in the order they happened.
|
||||||
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
syncMap: () => { for (const entry of [...vdesMessageHistory].reverse()) plotVdesMessage(entry); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,13 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
||||||
interface WefaxImage {
|
interface WefaxImage {
|
||||||
|
rig_id?: string | null;
|
||||||
ts_ms?: number;
|
ts_ms?: number;
|
||||||
ioc?: number;
|
ioc?: number;
|
||||||
lpm?: number;
|
lpm?: number;
|
||||||
@@ -20,6 +22,7 @@ interface WefaxImage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface WefaxProgress {
|
interface WefaxProgress {
|
||||||
|
rig_id?: string | null;
|
||||||
state?: string;
|
state?: string;
|
||||||
line_data?: string;
|
line_data?: string;
|
||||||
line_count?: number;
|
line_count?: number;
|
||||||
@@ -156,15 +159,21 @@ function paintLine(lineBytes: Uint8Array): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Live view: latest image card ────────────────────────────────────
|
// ── Live view: latest image card ────────────────────────────────────
|
||||||
|
/** The pictures the selected rig decoded, newest first. */
|
||||||
|
function wefaxRigImages(): WefaxImage[] {
|
||||||
|
return forActiveRig(wefaxImageHistory);
|
||||||
|
}
|
||||||
|
|
||||||
function renderWefaxLatestCard() {
|
function renderWefaxLatestCard() {
|
||||||
if (!wefaxDom.liveLatest) return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
if (wefaxImageHistory.length === 0) {
|
const rigImages = wefaxRigImages();
|
||||||
|
if (rigImages.length === 0) {
|
||||||
wefaxDom.liveLatest.innerHTML =
|
wefaxDom.liveLatest.innerHTML =
|
||||||
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable the decoder and tune to a WEFAX station.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const img = wefaxImageHistory[0];
|
const img = rigImages[0];
|
||||||
if (!img) return;
|
if (!img) return;
|
||||||
const ts = img._ts || '--';
|
const ts = img._ts || '--';
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||||
@@ -193,7 +202,8 @@ function renderWefaxLatestCard() {
|
|||||||
|
|
||||||
// ── History view: table ─────────────────────────────────────────────
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
function getWefaxFilteredHistory() {
|
function getWefaxFilteredHistory() {
|
||||||
let items = wefaxImageHistory;
|
// Every rig's pictures are kept; the panel shows the selected rig's.
|
||||||
|
let items = wefaxRigImages();
|
||||||
|
|
||||||
if (wefaxFilterText) {
|
if (wefaxFilterText) {
|
||||||
items = items.filter(function (i) {
|
items = items.filter(function (i) {
|
||||||
@@ -253,7 +263,7 @@ function renderWefaxHistoryTable() {
|
|||||||
wefaxDom.historyList.replaceChildren(fragment);
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
if (wefaxDom.historyCount) {
|
if (wefaxDom.historyCount) {
|
||||||
const total = wefaxImageHistory.length;
|
const total = wefaxRigImages().length;
|
||||||
const shown = items.length;
|
const shown = items.length;
|
||||||
wefaxDom.historyCount.textContent =
|
wefaxDom.historyCount.textContent =
|
||||||
total === 0
|
total === 0
|
||||||
@@ -298,6 +308,9 @@ function addWefaxImage(msg: WefaxImage): void {
|
|||||||
|
|
||||||
// ── SSE event handlers (public API) ─────────────────────────────────
|
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||||
function onServerWefaxProgress(msg: WefaxProgress): void {
|
function onServerWefaxProgress(msg: WefaxProgress): void {
|
||||||
|
// The live canvas draws one picture at a time: a background rig's lines
|
||||||
|
// would paint straight over the one being watched.
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
// State-only update (no image data): show decoder state in status.
|
// State-only update (no image data): show decoder state in status.
|
||||||
if (msg.state && !msg.line_data) {
|
if (msg.state && !msg.line_data) {
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -331,6 +344,8 @@ function onServerWefaxProgress(msg: WefaxProgress): void {
|
|||||||
|
|
||||||
function onServerWefax(msg: WefaxImage): void {
|
function onServerWefax(msg: WefaxImage): void {
|
||||||
addWefaxImage(msg);
|
addWefaxImage(msg);
|
||||||
|
// Kept in the history either way, so switching to that rig finds it there.
|
||||||
|
if (!isActiveRigDecode(msg.rig_id)) return;
|
||||||
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
if (wefaxDom.status) {
|
if (wefaxDom.status) {
|
||||||
@@ -438,6 +453,7 @@ wefaxWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: restoreWefaxHistory,
|
restore: restoreWefaxHistory,
|
||||||
prune: pruneWefaxHistoryView,
|
prune: pruneWefaxHistoryView,
|
||||||
reset: resetWefaxHistoryView,
|
reset: resetWefaxHistoryView,
|
||||||
|
rerender: () => { renderWefaxLatestCard(); renderWefaxHistoryTable(); },
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
id: "wefax_progress",
|
id: "wefax_progress",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { hostCore } from "./host.js";
|
import { hostCore } from "./host.js";
|
||||||
|
|
||||||
|
import { forActiveRig, isActiveRigDecode } from "./active-rig.js";
|
||||||
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
import type { PluginRuntimeWindow } from "./runtime-contract.js";
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
@@ -15,6 +16,10 @@ interface WsprMessage {
|
|||||||
snr_db?: number | undefined;
|
snr_db?: number | undefined;
|
||||||
dt_s?: number | undefined;
|
dt_s?: number | undefined;
|
||||||
freq_hz?: number | undefined;
|
freq_hz?: number | undefined;
|
||||||
|
rig_id?: string | null | undefined;
|
||||||
|
/** RF frequency the spot was heard on, kept so a map replay does not
|
||||||
|
* recompute it against wherever the dial has moved to since. */
|
||||||
|
_rfHz?: number | null | undefined;
|
||||||
receiver?: unknown;
|
receiver?: unknown;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
@@ -102,10 +107,12 @@ function renderWsprRow(msg: WsprMessage): HTMLDivElement {
|
|||||||
function renderWsprHistory(): void {
|
function renderWsprHistory(): void {
|
||||||
pruneWsprMessageHistory();
|
pruneWsprMessageHistory();
|
||||||
if (!wsprMessagesEl) return;
|
if (!wsprMessagesEl) return;
|
||||||
|
// The history holds every rig's decodes, for the map; the panel is about the
|
||||||
|
// rig the operator is working.
|
||||||
|
const rigMessages = forActiveRig(wsprMessageHistory);
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
for (const message of rigMessages) {
|
||||||
const message = wsprMessageHistory[i];
|
fragment.appendChild(renderWsprRow(message));
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
}
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
}
|
}
|
||||||
@@ -130,6 +137,8 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
station,
|
station,
|
||||||
rfHz,
|
rfHz,
|
||||||
history: {
|
history: {
|
||||||
|
rig_id: msg.rig_id ?? null,
|
||||||
|
_rfHz: rfHz,
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
ts_ms: msg.ts_ms,
|
ts_ms: msg.ts_ms,
|
||||||
snr_db: msg.snr_db,
|
snr_db: msg.snr_db,
|
||||||
@@ -142,16 +151,12 @@ function normalizeServerWsprMessage(msg: WsprMessage): { raw: string; grids: str
|
|||||||
|
|
||||||
function onServerWsprBatch(messages: WsprMessage[]): void {
|
function onServerWsprBatch(messages: WsprMessage[]): void {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized: WsprMessage[] = [];
|
const normalized: WsprMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
// "Receiving" is the panel's own status, and the panel is the rig's.
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
...msg,
|
plotWsprLocator(msg);
|
||||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
normalized.push(next.history);
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
@@ -311,15 +316,24 @@ document.getElementById("settings-clear-wspr-history")?.addEventListener("click"
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
function onServerWspr(msg: WsprMessage): void {
|
/** Hands a spot's grid squares to the map, if the map module is loaded yet.
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
* The module is lazy, so a spot heard before it arrived has to be replayable. */
|
||||||
|
function plotWsprLocator(msg: WsprMessage): void {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length === 0 || !wsprWindow.mapAddLocator) return;
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
// A replayed spot carries the frequency it was heard on; a fresh one has it
|
||||||
...msg,
|
// worked out from the dial it just arrived against.
|
||||||
...(next.rfHz === null ? {} : { freq_hz: next.rfHz }),
|
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: WsprMessage): void {
|
||||||
|
if (wsprStatus && isActiveRigDecode(msg.rig_id)) wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
plotWsprLocator(msg);
|
||||||
addWsprMessage(next.history);
|
addWsprMessage(next.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,4 +344,7 @@ wsprWindow.trxPluginRuntime.registerDecoder({
|
|||||||
restore: onServerWsprBatch,
|
restore: onServerWsprBatch,
|
||||||
prune: pruneWsprHistoryView,
|
prune: pruneWsprHistoryView,
|
||||||
reset: resetWsprHistoryView,
|
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); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -499,7 +499,7 @@ function elementById<T extends HTMLElement>(id: string): T {
|
|||||||
if (restoreFocus) more.focus();
|
if (restoreFocus) more.focus();
|
||||||
};
|
};
|
||||||
api.closeMobileOverlays = closeMore;
|
api.closeMobileOverlays = closeMore;
|
||||||
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
["satellites", "statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
if (!source) return;
|
if (!source) return;
|
||||||
const item = document.createElement("button");
|
const item = document.createElement("button");
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// The radio page and the digital modes page both describe one rig: the one the
|
||||||
|
// operator selected. Every rig a client is connected to feeds the same decode
|
||||||
|
// stream, so both the mini views over the waterfall and the panels behind them
|
||||||
|
// filter on the rig that heard each decode. The map is the exception and keeps
|
||||||
|
// all of them — it has its own rig filter — which is why the histories these
|
||||||
|
// views read from are never pruned by rig.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
import { createHost } from "./host-fixture.mjs";
|
||||||
|
|
||||||
|
class ElementFixture {
|
||||||
|
constructor(value = "") {
|
||||||
|
this.children = [];
|
||||||
|
this.innerHTML = "";
|
||||||
|
this.textContent = "";
|
||||||
|
this.value = value;
|
||||||
|
this.style = {};
|
||||||
|
this.dataset = {};
|
||||||
|
this.classList = { add() {}, remove() {}, toggle() {} };
|
||||||
|
}
|
||||||
|
appendChild(child) { this.children.push(child); return child; }
|
||||||
|
removeChild(child) { this.children.splice(this.children.indexOf(child), 1); }
|
||||||
|
replaceChildren(...nodes) { this.children = nodes.flatMap((node) => node.children ?? [node]); }
|
||||||
|
addEventListener() {}
|
||||||
|
setAttribute() {}
|
||||||
|
querySelector() { return null; }
|
||||||
|
querySelectorAll() { return []; }
|
||||||
|
get firstChild() { return this.children[0] ?? null; }
|
||||||
|
get lastElementChild() { return this.children.at(-1) ?? null; }
|
||||||
|
get scrollHeight() { return this.children.length; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A document whose named elements exist and whose unknown ones do not. */
|
||||||
|
function createDocument(elements) {
|
||||||
|
return {
|
||||||
|
documentElement: {},
|
||||||
|
getElementById: (id) => elements.get(id) ?? null,
|
||||||
|
querySelector: () => null,
|
||||||
|
querySelectorAll: () => [],
|
||||||
|
createElement: () => new ElementFixture(),
|
||||||
|
createDocumentFragment: () => new ElementFixture(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPlugin(entry, { window, document: doc, extras = {} }) {
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: doc,
|
||||||
|
navigator: {},
|
||||||
|
requestAnimationFrame(callback) { callback(); return 1; },
|
||||||
|
getComputedStyle: () => ({ getPropertyValue: () => "" }),
|
||||||
|
Date, Number, String, Math, Array, Map, Set, Reflect, console,
|
||||||
|
...extras,
|
||||||
|
});
|
||||||
|
const runtime = await bundleEntry(new URL("../src/plugin-runtime.ts", import.meta.url));
|
||||||
|
const source = await bundleEntry(new URL(`../src/plugins/${entry}.ts`, import.meta.url));
|
||||||
|
new vm.Script(runtime).runInContext(context);
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the APRS mini view and panel both keep to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const packets = new ElementFixture();
|
||||||
|
const totals = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["aprs-bar-overlay", overlay],
|
||||||
|
["aprs-packets", packets],
|
||||||
|
["aprs-total-count", totals],
|
||||||
|
["mode", new ElementFixture("PKT")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aprsMapAddStation: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/, "the active rig's frame is missing from the mini view");
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's frame reached the mini view");
|
||||||
|
assert.equal(packets.children.length, 1, "the APRS panel listed a background rig's frame");
|
||||||
|
assert.equal(totals.textContent, "1 total", `the APRS panel counted "${totals.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the APRS mini view shows every frame until a rig is known", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["aprs-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("PKT")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost(),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aprsMapAddStation: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||||
|
assert.match(overlay.innerHTML, /SP2BBB/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the AIS mini view keeps to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ais-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("AIS")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aisMapAddVessel: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("ais", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /NEARBY/);
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /ELSEWHERE/, "a background rig's vessel reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the VDES mini view keeps to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["vdes-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("VDES")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
vdesMapAddPoint: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("vdes", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's burst reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CW mini view keeps to the active rig and does not braid two rigs into a line", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["cw-bar-overlay", overlay],
|
||||||
|
["cw-output", new ElementFixture()],
|
||||||
|
["mode", new ElementFixture("CW")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
await runPlugin("cw", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "DX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /CQ SP1AAA/, "the active rig's line was broken up or lost");
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /DX/, "a background rig's characters reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the FT8 mini view keeps to the active rig", async () => {
|
||||||
|
const overlay = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ft8-bar-overlay", overlay],
|
||||||
|
["mode", new ElementFixture("DIG")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
ft8BaseHz: 7_074_000,
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
mapAddLocator: () => {},
|
||||||
|
setInterval() { return 1; },
|
||||||
|
};
|
||||||
|
await runPlugin("ft8", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.match(overlay.innerHTML, /SP1AAA/);
|
||||||
|
assert.doesNotMatch(overlay.innerHTML, /SP2BBB/, "a background rig's decode reached the mini view");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── The digital modes panels ───────────────────────────────────────────────
|
||||||
|
// The same rule one page over: a panel lists the selected rig's decodes only.
|
||||||
|
|
||||||
|
test("the AIS panel lists the active rig's vessels only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const count = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ais-messages", messages],
|
||||||
|
["ais-vessel-count", count],
|
||||||
|
["mode", new ElementFixture("AIS")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aisMapAddVessel: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("ais", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000001, vessel_name: "NEARBY", channel: "A", ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ais", { mmsi: 261000002, vessel_name: "ELSEWHERE", channel: "A", ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the AIS panel listed a background rig's vessel");
|
||||||
|
assert.equal(count.textContent, "1 vessel", `the panel counted "${count.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the VDES panel lists the active rig's bursts only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const count = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["vdes-messages", messages],
|
||||||
|
["vdes-frame-count", count],
|
||||||
|
["mode", new ElementFixture("VDES")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
vdesMapAddPoint: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("vdes", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP1AAA", bit_len: 120, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("vdes", { callsign: "SP2BBB", bit_len: 120, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the VDES panel listed a background rig's burst");
|
||||||
|
assert.equal(count.textContent, "1 burst", `the panel counted "${count.textContent}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the FT8 panel lists the active rig's decodes only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["ft8-messages", messages],
|
||||||
|
["mode", new ElementFixture("DIG")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
ft8BaseHz: 7_074_000,
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
mapAddLocator: () => {},
|
||||||
|
setInterval() { return 1; },
|
||||||
|
};
|
||||||
|
await runPlugin("ft8", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP1AAA JO91", freq_hz: 500, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("ft8", { message: "CQ SP2BBB JO94", freq_hz: 800, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the FT8 panel listed a background rig's decode");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the WSPR panel lists the active rig's spots only", async () => {
|
||||||
|
const messages = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["wspr-messages", messages],
|
||||||
|
["mode", new ElementFixture("DIG")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
ft8BaseHz: 14_095_600,
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
mapAddLocator: () => {},
|
||||||
|
};
|
||||||
|
await runPlugin("wspr", {
|
||||||
|
window,
|
||||||
|
document: createDocument(elements),
|
||||||
|
extras: { setInterval: () => 1 },
|
||||||
|
});
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
window.trxPluginRuntime.dispatch("wspr", { message: "SP1AAA JO91 30", freq_hz: 1_500, ts_ms: now, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("wspr", { message: "SP2BBB JO94 27", freq_hz: 1_520, ts_ms: now, rig_id: "rig-b" });
|
||||||
|
|
||||||
|
assert.equal(messages.children.length, 1, "the WSPR panel listed a background rig's spot");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the CW pane copies the active rig only", async () => {
|
||||||
|
const output = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["cw-output", output],
|
||||||
|
["cw-bar-overlay", new ElementFixture()],
|
||||||
|
["mode", new ElementFixture("CW")],
|
||||||
|
]);
|
||||||
|
const window = {
|
||||||
|
...createHost({ state: { lastActiveRigId: "rig-a" } }),
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
addEventListener() {},
|
||||||
|
};
|
||||||
|
await runPlugin("cw", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "CQ ", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "XX ", wpm: 22, tone_hz: 600, rig_id: "rig-b" });
|
||||||
|
window.trxPluginRuntime.dispatch("cw", { text: "SP1AAA", wpm: 18, tone_hz: 700, rig_id: "rig-a" });
|
||||||
|
|
||||||
|
const copied = output.children.map((line) => line.textContent).join("");
|
||||||
|
assert.equal(copied, "CQ SP1AAA", `the pane copied "${copied}"`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching rigs repaints the panels through the runtime", async () => {
|
||||||
|
const packets = new ElementFixture();
|
||||||
|
const elements = new Map([
|
||||||
|
["aprs-packets", packets],
|
||||||
|
["aprs-bar-overlay", new ElementFixture()],
|
||||||
|
["mode", new ElementFixture("PKT")],
|
||||||
|
]);
|
||||||
|
// A mutable host: the rig picker writes lastActiveRigId, and the panels read
|
||||||
|
// it as they paint.
|
||||||
|
const host = createHost({ state: { lastActiveRigId: "rig-a" } });
|
||||||
|
let activeRigId = "rig-a";
|
||||||
|
Object.defineProperty(host.trx.state, "lastActiveRigId", { get: () => activeRigId });
|
||||||
|
const window = { ...host, trxUi: { confirm: async () => true }, aprsMapAddStation: () => {} };
|
||||||
|
await runPlugin("aprs", { window, document: createDocument(elements) });
|
||||||
|
|
||||||
|
const frame = { dest_call: "APRS", packet_type: "position", crc_ok: true, info: "beacon" };
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP1AAA", rig_id: "rig-a" });
|
||||||
|
window.trxPluginRuntime.dispatch("aprs", { ...frame, src_call: "SP2BBB", rig_id: "rig-b" });
|
||||||
|
assert.equal(packets.children.length, 1);
|
||||||
|
|
||||||
|
activeRigId = "rig-b";
|
||||||
|
window.trxPluginRuntime.rerenderAll();
|
||||||
|
assert.equal(packets.children.length, 1, "the panel did not follow the switch");
|
||||||
|
const shown = packets.children.map((row) => row.innerHTML).join("");
|
||||||
|
assert.match(shown, /SP2BBB/, "the panel still shows the rig that was switched away from");
|
||||||
|
assert.doesNotMatch(shown, /SP1AAA/);
|
||||||
|
});
|
||||||
@@ -22,9 +22,25 @@ const BEACON = {
|
|||||||
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
packet_type: "position", crc_ok: true, lat: 54.35, lon: 18.65,
|
||||||
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
};
|
};
|
||||||
|
// A second rig listening in the background, on its own band. Both pages
|
||||||
|
// describe the rig on screen, so its traffic belongs in neither the panel nor
|
||||||
|
// the mini view — only on the map, which shows the whole station.
|
||||||
|
// HF APRS travels a different band and a different path from the VHF list, so
|
||||||
|
// it goes on the map under a source of its own that the filter can hide.
|
||||||
|
const HF_BEACON = {
|
||||||
|
type: "hf_aprs", src_call: "SP5HF-7", dest_call: "APRS", path: "WIDE2-2", info: "HF beacon",
|
||||||
|
packet_type: "position", crc_ok: true, lat: 50.06, lon: 19.94,
|
||||||
|
symbol_table: "/", symbol_code: ">", rig_id: "rig-a",
|
||||||
|
};
|
||||||
|
const OTHER_RIG_VESSEL = {
|
||||||
|
...VESSEL, mmsi: 244660001, vessel_name: "ELDERBERRY", callsign: "PBTY",
|
||||||
|
lat: 51.92, lon: 4.48, rig_id: "rig-b",
|
||||||
|
};
|
||||||
|
|
||||||
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
// AIS is what the mini view for vessels is gated on; the rig has to be on it.
|
||||||
const fixture = await startWebFixture({ spectrum: true, decodes: [VESSEL, BEACON], mode: "AIS" });
|
const fixture = await startWebFixture({
|
||||||
|
spectrum: true, decodes: [VESSEL, BEACON, HF_BEACON, OTHER_RIG_VESSEL], mode: "AIS",
|
||||||
|
});
|
||||||
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -41,11 +57,14 @@ try {
|
|||||||
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
aprs: document.getElementById("aprs-packets")?.children.length ?? 0,
|
||||||
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
aisStatus: document.getElementById("ais-status")?.textContent ?? "",
|
||||||
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
aprsStatus: document.getElementById("aprs-status")?.textContent ?? "",
|
||||||
|
otherRig: document.getElementById("ais-messages")?.textContent.includes("ELDERBERRY") ?? false,
|
||||||
|
aisRows: document.getElementById("ais-messages")?.children.length ?? 0,
|
||||||
mapLoaded: !!window.trx.modules.map,
|
mapLoaded: !!window.trx.modules.map,
|
||||||
}));
|
}));
|
||||||
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
assert.equal(panels.mapLoaded, false, "the map module was loaded, so this proves nothing");
|
||||||
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
assert.ok(panels.ais > 0, `the AIS panel is empty (status: ${panels.aisStatus})`);
|
||||||
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
assert.ok(panels.aprs > 0, `the APRS panel is empty (status: ${panels.aprsStatus})`);
|
||||||
|
assert.equal(panels.otherRig, false, "the AIS panel listed a background rig's vessel");
|
||||||
|
|
||||||
// The mini view rides over the waterfall on the radio page.
|
// The mini view rides over the waterfall on the radio page.
|
||||||
await page.locator('.tab[data-tab="main"]').click();
|
await page.locator('.tab[data-tab="main"]').click();
|
||||||
@@ -56,11 +75,13 @@ try {
|
|||||||
shown: getComputedStyle(bar).display !== "none",
|
shown: getComputedStyle(bar).display !== "none",
|
||||||
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
pins: bar.querySelectorAll(".aprs-bar-pin").length,
|
||||||
names: bar.textContent.includes("NEDERLAND"),
|
names: bar.textContent.includes("NEDERLAND"),
|
||||||
|
otherRig: bar.textContent.includes("ELDERBERRY"),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
assert.equal(miniView.shown, true, "the AIS mini view did not appear");
|
||||||
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
assert.ok(miniView.pins > 0, "the mini view has no pin to follow");
|
||||||
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
assert.equal(miniView.names, true, "the mini view does not name the vessel");
|
||||||
|
assert.equal(miniView.otherRig, false, "the mini view shows a background rig's vessel");
|
||||||
|
|
||||||
// Following the pin: the map opens, on the vessel. This is the path that was
|
// Following the pin: the map opens, on the vessel. This is the path that was
|
||||||
// broken for every decoder — the module that owned the navigation had not
|
// broken for every decoder — the module that owned the navigation had not
|
||||||
@@ -89,8 +110,41 @@ try {
|
|||||||
: Object.keys(collection ?? {}).length);
|
: Object.keys(collection ?? {}).length);
|
||||||
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
return { ais: size(map?.aisMarkers), stations: size(map?.stationMarkers) };
|
||||||
});
|
});
|
||||||
assert.ok(markers.ais > 0, "the vessel never reached the map");
|
|
||||||
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
assert.ok(markers.stations > 0, "the APRS station never reached the map");
|
||||||
|
// Both APRS stations are on it, each under its own source: the HF one used
|
||||||
|
// to be dropped entirely, since the HF list never plotted anything.
|
||||||
|
const aprsSources = await page.evaluate(() => {
|
||||||
|
const entries = [...(window.trx.modules.map?.stationMarkers ?? new Map()).entries()];
|
||||||
|
return entries.map(([key, entry]) => `${entry?.type ?? "?"}:${entry?.call ?? key}`).sort();
|
||||||
|
});
|
||||||
|
assert.deepEqual(aprsSources, ["aprs:SP2SJG-9", "hf_aprs:SP5HF-7"],
|
||||||
|
`the map holds ${JSON.stringify(aprsSources)}`);
|
||||||
|
// And the filter offers HF APRS on a chip of its own, next to the VHF one.
|
||||||
|
await page.locator('#map-locator-phase .map-locator-phase-btn[data-phase="type"]').click();
|
||||||
|
await page.waitForTimeout(400);
|
||||||
|
const chips = await page.evaluate(() => [...document.querySelectorAll("#map-locator-choice-filter .map-locator-chip")]
|
||||||
|
.map((chip) => chip.dataset.filterKey));
|
||||||
|
assert.ok(chips.includes("aprs") && chips.includes("hf_aprs"),
|
||||||
|
`the Show chips are ${JSON.stringify(chips)}`);
|
||||||
|
|
||||||
|
// Turning that chip off takes the HF station off the map and leaves the VHF
|
||||||
|
// one on: the two are filtered apart, which is the whole point of the split.
|
||||||
|
await page.locator('#map-locator-choice-filter .map-locator-chip[data-filter-key="hf_aprs"]').click();
|
||||||
|
await page.waitForTimeout(500);
|
||||||
|
const shownAfterFilter = await page.evaluate(() => {
|
||||||
|
const map = window.trx.modules.map;
|
||||||
|
const onMap = [];
|
||||||
|
(map?.stationMarkers ?? new Map()).forEach((entry, key) => {
|
||||||
|
if (entry?.marker && map.aprsMap?.hasLayer(entry.marker)) onMap.push(entry?.call ?? key);
|
||||||
|
});
|
||||||
|
return onMap.sort();
|
||||||
|
});
|
||||||
|
assert.deepEqual(shownAfterFilter, ["SP2SJG-9"],
|
||||||
|
`hiding HF APRS left ${JSON.stringify(shownAfterFilter)} on the map`);
|
||||||
|
// The map is the whole station's view — the one place a background rig's
|
||||||
|
// traffic belongs — so both vessels are on it even though the panel and the
|
||||||
|
// mini view show only the selected rig's.
|
||||||
|
assert.equal(markers.ais, 2, `${markers.ais} of 2 vessels reached the map`);
|
||||||
|
|
||||||
assert.deepEqual(runtimeErrors, []);
|
assert.deepEqual(runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -103,6 +157,8 @@ try {
|
|||||||
// left the client on its retry path and the history path untested.
|
// left the client on its retry path and the history path untested.
|
||||||
const HISTORY_AIS = 900;
|
const HISTORY_AIS = 900;
|
||||||
const HISTORY_APRS = 300;
|
const HISTORY_APRS = 300;
|
||||||
|
const HISTORY_FT8 = 12;
|
||||||
|
const HISTORY_WSPR = 8;
|
||||||
const historyFixture = await startWebFixture({
|
const historyFixture = await startWebFixture({
|
||||||
spectrum: true,
|
spectrum: true,
|
||||||
mode: "AIS",
|
mode: "AIS",
|
||||||
@@ -112,6 +168,14 @@ const historyFixture = await startWebFixture({
|
|||||||
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
vessel_name: `HISTORIC ${index}`, channel: "A", message_type: 1,
|
||||||
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
})),
|
})),
|
||||||
|
ft8: Array.from({ length: HISTORY_FT8 }, (_, index) => ({
|
||||||
|
message: `CQ SP${index}ABC JO${String(index).padStart(2, "0")}`, snr_db: -7, dt_s: 0.2,
|
||||||
|
freq_hz: 1200 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
|
wspr: Array.from({ length: HISTORY_WSPR }, (_, index) => ({
|
||||||
|
message: `SP${index}XYZ JN${String(index).padStart(2, "0")} 30`, snr_db: -22, dt_s: 0.5,
|
||||||
|
freq_hz: 1500 + index, rig_id: "rig-a", ts_ms: Date.now() - (index + 1) * 1000,
|
||||||
|
})),
|
||||||
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
aprs: Array.from({ length: HISTORY_APRS }, (_, index) => ({
|
||||||
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
src_call: `SP2SJG-${index % 15}`, dest_call: "APRS", path: "WIDE1-1",
|
||||||
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
info: `history ${index}`, packet_type: "position", crc_ok: true,
|
||||||
@@ -199,6 +263,27 @@ try {
|
|||||||
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
assert.equal(plotted.ais, HISTORY_AIS, `${plotted.ais} of ${HISTORY_AIS} vessels reached the map`);
|
||||||
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
assert.equal(plotted.stations, 15, `${plotted.stations} of 15 stations reached the map`);
|
||||||
|
|
||||||
|
// The Statistics page counts the same history. Its decode log lives in the
|
||||||
|
// map module, which is lazy, so every decode that arrived before the module
|
||||||
|
// did used to be recorded into nothing at all — the page opened empty and
|
||||||
|
// only filled in from decodes heard afterwards, and it took a reload landing
|
||||||
|
// on the tab (module loaded at startup, before the history) to show the lot.
|
||||||
|
await replay.page.evaluate(() => window.navigateToTab("statistics"));
|
||||||
|
await replay.page.waitForTimeout(1500);
|
||||||
|
// The counters are written with toLocaleString(), so a four-figure count
|
||||||
|
// arrives as "1,220" — whichever separator the runner's locale picks. Read
|
||||||
|
// the digits rather than the formatting.
|
||||||
|
const counted = await replay.page.evaluate(() => {
|
||||||
|
const count = (id) => Number((document.getElementById(id)?.textContent ?? "").replace(/\D/g, ""));
|
||||||
|
return { decodes: count("stats-total-decodes"), grids: count("stats-unique-grids") };
|
||||||
|
});
|
||||||
|
assert.equal(counted.decodes, HISTORY_AIS + HISTORY_APRS + HISTORY_FT8 + HISTORY_WSPR,
|
||||||
|
`the statistics counted ${counted.decodes} decodes`);
|
||||||
|
// Grid squares come from the FT8 and WSPR spots, which had no map replay of
|
||||||
|
// their own: the locators of everything heard before the map loaded were lost.
|
||||||
|
assert.equal(counted.grids, HISTORY_FT8 + HISTORY_WSPR,
|
||||||
|
`the statistics counted ${counted.grids} grid squares`);
|
||||||
|
|
||||||
assert.deepEqual(replay.runtimeErrors, []);
|
assert.deepEqual(replay.runtimeErrors, []);
|
||||||
} finally {
|
} finally {
|
||||||
await replay.browser.close();
|
await replay.browser.close();
|
||||||
|
|||||||
+122
@@ -0,0 +1,122 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// Pass predictions used to be a third view inside the weather-satellite
|
||||||
|
// decoder, under Digital modes — a planning tool filed with the decoders it has
|
||||||
|
// nothing to do with. It is its own page now, so what this guards is that the
|
||||||
|
// page exists at its own address, renders passes, and that the decoder card no
|
||||||
|
// longer offers the view it gave up.
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
import { startBrowser, startWebFixture } from "./web-fixture.mjs";
|
||||||
|
|
||||||
|
/* global document, window, getComputedStyle */
|
||||||
|
|
||||||
|
const HOUR = 3_600_000;
|
||||||
|
const now = Date.now();
|
||||||
|
const satPasses = {
|
||||||
|
satellite_count: 2,
|
||||||
|
passes: [
|
||||||
|
// In progress right now, so the page has a countdown to run.
|
||||||
|
{
|
||||||
|
satellite: "NOAA 19", category: "weather",
|
||||||
|
aos_ms: now - 4 * 60_000, los_ms: now + 6 * 60_000,
|
||||||
|
max_elevation_deg: 62.4, duration_s: 600,
|
||||||
|
azimuth_aos_deg: 10, azimuth_los_deg: 190,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
satellite: "ISS", category: "amateur",
|
||||||
|
aos_ms: now + 2 * HOUR, los_ms: now + 2 * HOUR + 480_000,
|
||||||
|
max_elevation_deg: 18.2, duration_s: 480,
|
||||||
|
azimuth_aos_deg: 200, azimuth_los_deg: 20,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const fixture = await startWebFixture({ satPasses });
|
||||||
|
const { browser, page, runtimeErrors } = await startBrowser(chromium);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
|
||||||
|
// The page is reachable at its own address, not through a decoder sub-view.
|
||||||
|
// #content is the radio panel on the main tab, so the readiness signal for a
|
||||||
|
// deep link is the destination panel itself.
|
||||||
|
await page.goto(`${fixture.origin}/satellites`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-satellites").waitFor({ state: "visible" });
|
||||||
|
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
||||||
|
null,
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const rendered = await page.evaluate(() => ({
|
||||||
|
current: document.getElementById("sat-pred-current-list").textContent,
|
||||||
|
upcoming: document.getElementById("sat-pred-list").textContent,
|
||||||
|
status: document.getElementById("sat-pred-status").textContent,
|
||||||
|
activeTab: document.querySelector(".tab-bar .tab.active")?.dataset.tab,
|
||||||
|
}));
|
||||||
|
assert.match(rendered.current, /NOAA 19/, `current passes read "${rendered.current}"`);
|
||||||
|
assert.match(rendered.upcoming, /ISS/, `upcoming passes read "${rendered.upcoming}"`);
|
||||||
|
assert.match(rendered.status, /1 active/, `status reads "${rendered.status}"`);
|
||||||
|
assert.equal(rendered.activeTab, "satellites", "the Satellites tab is the active one");
|
||||||
|
|
||||||
|
// A pass in progress counts down, so the seconds have to move on their own.
|
||||||
|
const firstTick = await page.evaluate(
|
||||||
|
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
||||||
|
await page.waitForTimeout(1600);
|
||||||
|
const secondTick = await page.evaluate(
|
||||||
|
() => document.querySelector(".sat-pred-col-countdown[data-los]")?.textContent);
|
||||||
|
assert.notEqual(firstTick, secondTick, `the countdown sat at ${firstTick}`);
|
||||||
|
|
||||||
|
// Leaving stops the countdown rather than leaving a timer running behind a
|
||||||
|
// hidden page.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("main"); });
|
||||||
|
await page.waitForTimeout(300);
|
||||||
|
const afterLeaving = await page.evaluate(() => ({
|
||||||
|
visible: document.getElementById("tab-satellites").style.display,
|
||||||
|
rows: document.getElementById("sat-pred-current-list").childElementCount,
|
||||||
|
}));
|
||||||
|
assert.equal(afterLeaving.visible, "none", "the page stayed on screen after navigating away");
|
||||||
|
assert.equal(afterLeaving.rows, 0, "the countdown rows outlived the page");
|
||||||
|
|
||||||
|
// Coming back reloads rather than showing whatever was there before.
|
||||||
|
await page.evaluate(() => { window.navigateToTab("satellites"); });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => (document.getElementById("sat-pred-list")?.childElementCount ?? 0) > 0,
|
||||||
|
null,
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
|
||||||
|
// The page is an occasional destination, so it lives behind Tools rather than
|
||||||
|
// taking a slot in the operating strip — the same treatment Statistics,
|
||||||
|
// Recorder, Settings and About get.
|
||||||
|
const nav = await page.evaluate(() => ({
|
||||||
|
inStrip: getComputedStyle(document.querySelector('.tab-bar-nav .tab[data-tab="satellites"]')).display,
|
||||||
|
inTools: !!document.querySelector('#mobile-more-menu [data-navigate-tab="satellites"]'),
|
||||||
|
}));
|
||||||
|
assert.equal(nav.inStrip, "none", "Satellites took a slot in the operating strip");
|
||||||
|
assert.ok(nav.inTools, "Satellites is not reachable from Tools");
|
||||||
|
|
||||||
|
// The decoder card keeps Live and History, and no longer offers Predictions.
|
||||||
|
await page.goto(`${fixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" });
|
||||||
|
await page.locator("#tab-digital-modes").waitFor({ state: "visible" });
|
||||||
|
const satCard = await page.evaluate(() => ({
|
||||||
|
predictionsButton: !!document.getElementById("sat-view-predictions"),
|
||||||
|
predictionsView: !!document.getElementById("sat-predictions-view"),
|
||||||
|
live: !!document.getElementById("sat-view-live"),
|
||||||
|
history: !!document.getElementById("sat-view-history"),
|
||||||
|
}));
|
||||||
|
assert.equal(satCard.predictionsButton, false, "the decoder card still offers Predictions");
|
||||||
|
assert.equal(satCard.predictionsView, false, "the old predictions view is still in the page");
|
||||||
|
assert.ok(satCard.live && satCard.history, "the decoder card lost Live/History");
|
||||||
|
|
||||||
|
assert.deepEqual(runtimeErrors, [], "the page threw while showing predictions");
|
||||||
|
console.log("satellite predictions page tests passed");
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
await fixture.close();
|
||||||
|
}
|
||||||
@@ -27,6 +27,21 @@ const dial = () => page.evaluate(() => ({
|
|||||||
path: window.location.pathname,
|
path: window.location.pathname,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Typed, not filled. The app holds back its own refreshes of the frequency
|
||||||
|
// field from the first keystroke until Enter, so that a state update arriving
|
||||||
|
// mid-edit does not rewrite what is being typed. `fill()` sets the value
|
||||||
|
// without a keystroke, leaving the field unguarded: on a slow machine a state
|
||||||
|
// update could land between the fill and the Enter and put the old frequency
|
||||||
|
// back, and the Enter would then re-apply the frequency the radio was already
|
||||||
|
// on. Selecting first arms the guard before a single character changes.
|
||||||
|
async function tuneByHand(text) {
|
||||||
|
const field = page.locator("#freq");
|
||||||
|
await field.click();
|
||||||
|
await field.press("ControlOrMeta+a");
|
||||||
|
await field.pressSequentially(text);
|
||||||
|
await field.press("Enter");
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.setViewportSize({ width: 1500, height: 950 });
|
await page.setViewportSize({ width: 1500, height: 950 });
|
||||||
|
|
||||||
@@ -49,8 +64,7 @@ try {
|
|||||||
|
|
||||||
// Tuning by hand rewrites the link. This is the part that makes the address
|
// Tuning by hand rewrites the link. This is the part that makes the address
|
||||||
// bar shareable at any moment rather than only at load.
|
// bar shareable at any moment rather than only at load.
|
||||||
await page.locator("#freq").fill("7.040M");
|
await tuneByHand("7.040M");
|
||||||
await page.locator("#freq").press("Enter");
|
|
||||||
await page.waitForTimeout(1500);
|
await page.waitForTimeout(1500);
|
||||||
const tuned = await dial();
|
const tuned = await dial();
|
||||||
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
assert.equal(tuned.freqHz, TUNED_HZ, `tuning landed on ${tuned.freqHz} Hz`);
|
||||||
@@ -59,8 +73,7 @@ try {
|
|||||||
|
|
||||||
// Tuning is not navigation: a swept dial must not bury the back button.
|
// Tuning is not navigation: a swept dial must not bury the back button.
|
||||||
const historyLength = await page.evaluate(() => window.history.length);
|
const historyLength = await page.evaluate(() => window.history.length);
|
||||||
await page.locator("#freq").fill("7.100M");
|
await tuneByHand("7.100M");
|
||||||
await page.locator("#freq").press("Enter");
|
|
||||||
await page.waitForTimeout(1200);
|
await page.waitForTimeout(1200);
|
||||||
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
assert.equal(await page.evaluate(() => window.history.length), historyLength,
|
||||||
"tuning pushed a history entry instead of replacing one");
|
"tuning pushed a history entry instead of replacing one");
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ export async function startWebFixture({
|
|||||||
bandplan = {},
|
bandplan = {},
|
||||||
bandplanEnabled = false,
|
bandplanEnabled = false,
|
||||||
bandplanUnauthorizedFirst = false,
|
bandplanUnauthorizedFirst = false,
|
||||||
|
satPasses = null,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
const rigItems = ["rig-a", "rig-b"].map((remote) => ({
|
||||||
remote,
|
remote,
|
||||||
@@ -230,6 +231,7 @@ export async function startWebFixture({
|
|||||||
["/bandplan.json", bandplan],
|
["/bandplan.json", bandplan],
|
||||||
["/api/recorder/status", []],
|
["/api/recorder/status", []],
|
||||||
["/api/recorder/files", []],
|
["/api/recorder/files", []],
|
||||||
|
["/sat_passes", satPasses ?? { satellite_count: 0, passes: [] }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
// Flat i8 bins: the shape does not matter, only that frames arrive so the
|
||||||
|
|||||||
@@ -101,6 +101,12 @@ pub(crate) async fn statistics_index(req: HttpRequest) -> impl Responder {
|
|||||||
static_asset_response(&req, "text/html; charset=utf-8", c)
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[get("/satellites")]
|
||||||
|
pub(crate) async fn satellites_index(req: HttpRequest) -> impl Responder {
|
||||||
|
let c = gz_index_html();
|
||||||
|
static_asset_response(&req, "text/html; charset=utf-8", c)
|
||||||
|
}
|
||||||
|
|
||||||
#[get("/bookmarks")]
|
#[get("/bookmarks")]
|
||||||
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
pub(crate) async fn bookmarks_index(req: HttpRequest) -> impl Responder {
|
||||||
let c = gz_index_html();
|
let c = gz_index_html();
|
||||||
|
|||||||
@@ -639,6 +639,7 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
|
|||||||
.service(assets::recorder_index)
|
.service(assets::recorder_index)
|
||||||
.service(assets::settings_index)
|
.service(assets::settings_index)
|
||||||
.service(assets::about_index)
|
.service(assets::about_index)
|
||||||
|
.service(assets::satellites_index)
|
||||||
.service(assets::statistics_index)
|
.service(assets::statistics_index)
|
||||||
.service(assets::bookmarks_index)
|
.service(assets::bookmarks_index)
|
||||||
.service(assets::favicon)
|
.service(assets::favicon)
|
||||||
|
|||||||
Reference in New Issue
Block a user