build: add frontend TypeScript toolchain
This commit is contained in:
@@ -36,6 +36,24 @@ jobs:
|
|||||||
- name: Test
|
- name: Test
|
||||||
run: cargo test --workspace --locked
|
run: cargo test --workspace --locked
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
defaults:
|
||||||
|
run:
|
||||||
|
working-directory: src/trx-client/trx-frontend/trx-frontend-http/frontend
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Install locked frontend dependencies
|
||||||
|
run: npm ci
|
||||||
|
- name: Type-check
|
||||||
|
run: npm run typecheck
|
||||||
|
- name: Lint
|
||||||
|
run: npm run lint
|
||||||
|
- name: Test
|
||||||
|
run: npm test
|
||||||
|
- name: Verify generated assets
|
||||||
|
run: npm run verify-generated
|
||||||
|
|
||||||
reuse:
|
reuse:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ Cargo.lock
|
|||||||
coverage/
|
coverage/
|
||||||
benchmarks/
|
benchmarks/
|
||||||
|
|
||||||
|
# Frontend dependencies
|
||||||
|
**/node_modules/
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
|
|||||||
@@ -0,0 +1,335 @@
|
|||||||
|
"use strict";
|
||||||
|
const aisStatus = document.getElementById("ais-status");
|
||||||
|
const aisMessagesEl = document.getElementById("ais-messages");
|
||||||
|
const aisFilterInput = document.getElementById("ais-filter");
|
||||||
|
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
|
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
|
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
|
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||||
|
const AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
const AIS_DEFAULT_A_HZ = 161975e3;
|
||||||
|
const AIS_CHANNEL_SPACING_HZ = 5e4;
|
||||||
|
let aisFilterText = "";
|
||||||
|
let aisMessageHistory = [];
|
||||||
|
function currentAisHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAisMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
|
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAisUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleAisHistoryRender() {
|
||||||
|
scheduleAisUi("ais-history", () => renderAisHistory());
|
||||||
|
}
|
||||||
|
function scheduleAisBarUpdate() {
|
||||||
|
scheduleAisUi("ais-bar", () => updateAisBar());
|
||||||
|
}
|
||||||
|
function formatAisMhz(freqHz) {
|
||||||
|
return `${(freqHz / 1e6).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
function currentAisChannelPlan() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
|
return {
|
||||||
|
aHz: safeAHz,
|
||||||
|
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function aisChannelInfo(channel) {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
const ch = String(channel || "").trim().toUpperCase();
|
||||||
|
if (ch === "B") {
|
||||||
|
return {
|
||||||
|
label: "AIS-B",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-b",
|
||||||
|
freqText: formatAisMhz(plan.bHz)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "AIS-A",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-a",
|
||||||
|
freqText: formatAisMhz(plan.aHz)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function aisDisplayName(msg) {
|
||||||
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
|
}
|
||||||
|
function aisDisplayNameHtml(msg) {
|
||||||
|
const label = escapeMapHtml(aisDisplayName(msg));
|
||||||
|
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||||
|
if (!url) return label;
|
||||||
|
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
|
}
|
||||||
|
function aisTypeLabel(type) {
|
||||||
|
switch (Number(type)) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return "Class A Position";
|
||||||
|
case 4:
|
||||||
|
return "Base Station";
|
||||||
|
case 5:
|
||||||
|
return "Static/Voyage";
|
||||||
|
case 18:
|
||||||
|
return "Class B Position";
|
||||||
|
case 19:
|
||||||
|
return "Class B Extended";
|
||||||
|
case 21:
|
||||||
|
return "Aid to Nav";
|
||||||
|
case 24:
|
||||||
|
return "Class B Static";
|
||||||
|
default:
|
||||||
|
return `Type ${type ?? "--"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aisAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function aisMotionText(msg) {
|
||||||
|
const parts = [
|
||||||
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
||||||
|
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
function aisRouteText(msg) {
|
||||||
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
|
}
|
||||||
|
function aisDistanceText(msg) {
|
||||||
|
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function aisLatestByVessel(messages) {
|
||||||
|
const byMmsi = /* @__PURE__ */ new Map();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
|
}
|
||||||
|
return Array.from(byMmsi.values());
|
||||||
|
}
|
||||||
|
function updateAisSummary() {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
if (aisChannelSummaryEl) {
|
||||||
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
|
}
|
||||||
|
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||||
|
if (aisVesselCountEl) {
|
||||||
|
const count = vessels.length;
|
||||||
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (aisLatestSeenEl) {
|
||||||
|
const latest = aisMessageHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
|
} else {
|
||||||
|
const channel = aisChannelInfo(latest.channel);
|
||||||
|
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderAisRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ais-message";
|
||||||
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const name = aisDisplayName(msg);
|
||||||
|
const nameHtml = aisDisplayNameHtml(msg);
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
|
||||||
|
row.dataset.filterText = [
|
||||||
|
name,
|
||||||
|
msg.mmsi,
|
||||||
|
msg.channel,
|
||||||
|
channel.label,
|
||||||
|
msg.vessel_name,
|
||||||
|
msg.callsign,
|
||||||
|
msg.destination,
|
||||||
|
aisTypeLabel(msg.message_type)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeMapHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
||||||
|
applyAisFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function applyAisFilterToRow(row) {
|
||||||
|
if (!aisFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.filterText || "";
|
||||||
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function applyAisFilterToAll() {
|
||||||
|
if (!aisMessagesEl) return;
|
||||||
|
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
||||||
|
rows.forEach((row) => applyAisFilterToRow(row));
|
||||||
|
}
|
||||||
|
function updateAisBar() {
|
||||||
|
if (!aisBarOverlay) return;
|
||||||
|
updateAisSummary();
|
||||||
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
|
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||||
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
|
if (!isAis || messages.length === 0) {
|
||||||
|
aisBarOverlay.style.display = "none";
|
||||||
|
aisBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const pin = msg.lat != null && msg.lon != null ? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>` : "";
|
||||||
|
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const details = [
|
||||||
|
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||||
|
escapeMapHtml(channel.label),
|
||||||
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||||
|
distance ? escapeMapHtml(distance) : null,
|
||||||
|
escapeMapHtml(aisAgeText(msg._tsMs))
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
||||||
|
}
|
||||||
|
aisBarOverlay.innerHTML = html;
|
||||||
|
aisBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateAisBar = updateAisBar;
|
||||||
|
window.clearAisBar = function() {
|
||||||
|
window.resetAisHistoryView();
|
||||||
|
};
|
||||||
|
window.resetAisHistoryView = function() {
|
||||||
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
|
aisMessageHistory = [];
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
||||||
|
};
|
||||||
|
function renderAisHistory() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
if (!aisMessagesEl) {
|
||||||
|
updateAisSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
||||||
|
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
||||||
|
}
|
||||||
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
|
updateAisSummary();
|
||||||
|
}
|
||||||
|
function addAisMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
aisMessageHistory.unshift(msg);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
||||||
|
window.aisMapAddVessel(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeServerAisMessage(msg) {
|
||||||
|
return {
|
||||||
|
rig_id: msg.rig_id || null,
|
||||||
|
channel: msg.channel,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
mmsi: msg.mmsi,
|
||||||
|
lat: msg.lat,
|
||||||
|
lon: msg.lon,
|
||||||
|
sog_knots: msg.sog_knots,
|
||||||
|
cog_deg: msg.cog_deg,
|
||||||
|
heading_deg: msg.heading_deg,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
ts_ms: msg.ts_ms
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerAisBatch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerAisMessage(msg);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
||||||
|
window.aisMapAddVessel(next);
|
||||||
|
}
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreAisHistory = function(messages) {
|
||||||
|
window.onServerAisBatch(messages);
|
||||||
|
};
|
||||||
|
window.pruneAisHistoryView = function() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
};
|
||||||
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ais_decode");
|
||||||
|
window.resetAisHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AIS history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (aisFilterInput) {
|
||||||
|
aisFilterInput.addEventListener("input", () => {
|
||||||
|
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.onServerAis = function(msg) {
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
|
};
|
||||||
|
updateAisSummary();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
|||||||
|
"use strict";
|
||||||
|
const aprsStatus = document.getElementById("aprs-status");
|
||||||
|
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
|
const aprsFilterInput = document.getElementById("aprs-filter");
|
||||||
|
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
|
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
|
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
|
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||||
|
const aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||||
|
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||||
|
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
|
const APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
let aprsFilterText = "";
|
||||||
|
let aprsPacketHistory = [];
|
||||||
|
let aprsBarDismissedAtMs = 0;
|
||||||
|
let aprsOnlyPos = false;
|
||||||
|
let aprsHideCrc = false;
|
||||||
|
let aprsCollapseDup = false;
|
||||||
|
let aprsTypeFilter = "all";
|
||||||
|
function currentAprsHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAprsUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleAprsHistoryRender() {
|
||||||
|
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
||||||
|
}
|
||||||
|
function scheduleAprsBarUpdate() {
|
||||||
|
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
||||||
|
}
|
||||||
|
function renderAprsInfo(pkt) {
|
||||||
|
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||||
|
if (bytes && bytes.length > 0) {
|
||||||
|
let out2 = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i];
|
||||||
|
if (b >= 32 && b <= 126) {
|
||||||
|
const ch = String.fromCharCode(b);
|
||||||
|
if (ch === "<") out2 += "<";
|
||||||
|
else if (ch === ">") out2 += ">";
|
||||||
|
else if (ch === "&") out2 += "&";
|
||||||
|
else if (ch === '"') out2 += """;
|
||||||
|
else out2 += ch;
|
||||||
|
} else {
|
||||||
|
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out2 += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out2;
|
||||||
|
}
|
||||||
|
const str = pkt.info || "";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code >= 32 && code <= 126) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function aprsPacketCategory(pkt) {
|
||||||
|
const type = String(pkt.type || "").toLowerCase();
|
||||||
|
const info = String(pkt.info || "").toLowerCase();
|
||||||
|
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function aprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position":
|
||||||
|
return "Position";
|
||||||
|
case "message":
|
||||||
|
return "Message";
|
||||||
|
case "weather":
|
||||||
|
return "Weather";
|
||||||
|
case "telemetry":
|
||||||
|
return "Telemetry";
|
||||||
|
default:
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function aprsAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function aprsDistanceText(pkt) {
|
||||||
|
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function aprsPacketSignature(pkt) {
|
||||||
|
return [
|
||||||
|
pkt.srcCall || "",
|
||||||
|
pkt.destCall || "",
|
||||||
|
pkt.path || "",
|
||||||
|
pkt.info || "",
|
||||||
|
pkt.type || "",
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||||
|
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function aprsFilterMatch(pkt) {
|
||||||
|
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||||
|
if (!aprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
aprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(aprsFilterText);
|
||||||
|
}
|
||||||
|
function aprsVisiblePackets() {
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
|
return packets.filter(aprsFilterMatch);
|
||||||
|
}
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const key = aprsPacketSignature(pkt);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(pkt);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function updateAprsSummary() {
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
if (aprsTotalCountEl) {
|
||||||
|
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||||
|
}
|
||||||
|
if (aprsVisibleCountEl) {
|
||||||
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
|
}
|
||||||
|
if (aprsLatestSeenEl) {
|
||||||
|
const latest = aprsPacketHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
||||||
|
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||||
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
|
}
|
||||||
|
function renderAprsRow(pkt, isFresh) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
|
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const age = aprsAgeText(pkt._tsMs);
|
||||||
|
const category = aprsPacketCategory(pkt);
|
||||||
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||||
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
|
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
||||||
|
const distance = aprsDistanceText(pkt);
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
|
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span><span>>${escapeMapHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeMapHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = String(el.dataset.aprsMap || "");
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
window.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(raw);
|
||||||
|
showHint("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
showHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderAprsHistory() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (!aprsPacketsEl) {
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < visible.length; i++) {
|
||||||
|
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
||||||
|
}
|
||||||
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
}
|
||||||
|
function updateAprsBar() {
|
||||||
|
if (!aprsBarOverlay) return;
|
||||||
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
||||||
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>`;
|
||||||
|
for (const pkt of frames) {
|
||||||
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
|
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
||||||
|
const dest = escapeMapHtml(pkt.destCall || "");
|
||||||
|
const info = escapeMapHtml(pkt.info || "");
|
||||||
|
const pin = pkt.lat != null && pkt.lon != null ? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>` : "";
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div></div>`;
|
||||||
|
}
|
||||||
|
aprsBarOverlay.innerHTML = html;
|
||||||
|
aprsBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateAprsBar = updateAprsBar;
|
||||||
|
window.clearAprsBar = function() {
|
||||||
|
window.resetAprsHistoryView();
|
||||||
|
};
|
||||||
|
window.closeAprsBar = function() {
|
||||||
|
aprsBarDismissedAtMs = Date.now();
|
||||||
|
if (aprsBarOverlay) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.resetAprsHistoryView = function() {
|
||||||
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
|
aprsPacketHistory = [];
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
||||||
|
};
|
||||||
|
window.pruneAprsHistoryView = function() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
};
|
||||||
|
function addAprsPacket(pkt) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
aprsPacketHistory.unshift(pkt);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||||
|
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerAprsPacket(pkt) {
|
||||||
|
return {
|
||||||
|
rig_id: pkt.rig_id || null,
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
srcCall: pkt.src_call,
|
||||||
|
destCall: pkt.dest_call,
|
||||||
|
path: pkt.path,
|
||||||
|
info: pkt.info,
|
||||||
|
info_bytes: pkt.info_bytes,
|
||||||
|
type: pkt.packet_type,
|
||||||
|
crcOk: pkt.crc_ok,
|
||||||
|
ts_ms: pkt.ts_ms,
|
||||||
|
lat: pkt.lat,
|
||||||
|
lon: pkt.lon,
|
||||||
|
symbolTable: pkt.symbol_table,
|
||||||
|
symbolCode: pkt.symbol_code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerAprsBatch = function(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
aprsStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
let hasCrcOk = false;
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
||||||
|
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
||||||
|
}
|
||||||
|
if (next.crcOk) hasCrcOk = true;
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aprsPacketHistory = normalized.concat(aprsPacketHistory);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreAprsHistory = function(packets) {
|
||||||
|
window.onServerAprsBatch(packets);
|
||||||
|
};
|
||||||
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_aprs_decode");
|
||||||
|
window.resetAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (aprsOnlyPosBtn) {
|
||||||
|
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
aprsOnlyPos = !aprsOnlyPos;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aprsHideCrcBtn) {
|
||||||
|
aprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
aprsHideCrc = !aprsHideCrc;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (aprsCollapseDupBtn) {
|
||||||
|
aprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
aprsCollapseDup = !aprsCollapseDup;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
aprsTypeFilter = type;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (aprsFilterInput) {
|
||||||
|
aprsFilterInput.addEventListener("input", () => {
|
||||||
|
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.onServerAprs = function(pkt) {
|
||||||
|
aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
|
};
|
||||||
|
renderAprsHistory();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
||||||
+355
@@ -0,0 +1,355 @@
|
|||||||
|
"use strict";
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
function bgdSupportedIds() {
|
||||||
|
return (window.decoderRegistry || []).filter(function(d) {
|
||||||
|
return d.background_decode;
|
||||||
|
}).map(function(d) {
|
||||||
|
return d.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let backgroundDecodeRole = null;
|
||||||
|
let currentRigId = null;
|
||||||
|
let currentConfig = null;
|
||||||
|
let bookmarkList = [];
|
||||||
|
let statusInterval = null;
|
||||||
|
let bgdDirty = false;
|
||||||
|
function initBackgroundDecode(rigId, role) {
|
||||||
|
backgroundDecodeRole = role;
|
||||||
|
currentRigId = rigId || null;
|
||||||
|
if (currentRigId) loadBackgroundDecode();
|
||||||
|
startStatusPolling();
|
||||||
|
}
|
||||||
|
function setBackgroundDecodeRig(rigId) {
|
||||||
|
const nextRigId = rigId || null;
|
||||||
|
if (nextRigId === currentRigId) return;
|
||||||
|
currentRigId = nextRigId;
|
||||||
|
if (!currentRigId) return;
|
||||||
|
loadBackgroundDecode();
|
||||||
|
}
|
||||||
|
function apiGetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiPutConfig(rigId, config) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config)
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiResetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "DELETE"
|
||||||
|
}).then(function(r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetStatus(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetBookmarks() {
|
||||||
|
return fetch("/bookmarks").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) {
|
||||||
|
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
||||||
|
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
}).catch(function(err) {
|
||||||
|
console.error("background decode load failed", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function supportedBookmarks() {
|
||||||
|
return bookmarkList.filter(function(bookmark) {
|
||||||
|
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bookmarkDecoderKinds(bookmark) {
|
||||||
|
var ids = bgdSupportedIds();
|
||||||
|
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
||||||
|
var explicit = decoders.map(function(item) {
|
||||||
|
return String(item || "").trim().toLowerCase();
|
||||||
|
}).filter(function(item, index, arr) {
|
||||||
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
|
});
|
||||||
|
if (explicit.length > 0) return explicit;
|
||||||
|
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
||||||
|
return (window.decoderRegistry || []).filter(function(d) {
|
||||||
|
return d.activation === "mode_bound" && d.background_decode && d.active_modes.indexOf(mode) >= 0;
|
||||||
|
}).map(function(d) {
|
||||||
|
return d.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderBackgroundDecode() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
||||||
|
renderBookmarkChecklist();
|
||||||
|
const isControl = backgroundDecodeRole === "control" || typeof authEnabled !== "undefined" && !authEnabled;
|
||||||
|
const panel = document.getElementById("background-decode-panel");
|
||||||
|
if (panel) {
|
||||||
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||||
|
el.disabled = !isControl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
}
|
||||||
|
function renderBookmarkChecklist(filterText) {
|
||||||
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
const selectedIds = new Set(
|
||||||
|
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
||||||
|
);
|
||||||
|
const all = supportedBookmarks();
|
||||||
|
const filter = (filterText || "").trim().toLowerCase();
|
||||||
|
const filtered = filter ? all.filter(function(bm) {
|
||||||
|
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||||
|
return text.indexOf(filter) >= 0;
|
||||||
|
}) : all;
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="bgd-checklist-empty">' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "</div>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filtered.forEach(function(bookmark) {
|
||||||
|
var row = document.createElement("label");
|
||||||
|
row.className = "bgd-checklist-row";
|
||||||
|
var decoders = bookmarkDecoderKinds(bookmark);
|
||||||
|
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
|
row.innerHTML = '<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" /><span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span><span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + "</span>";
|
||||||
|
row.querySelector("input").addEventListener("change", function(e) {
|
||||||
|
onChecklistToggle(bookmark.id, e.target.checked);
|
||||||
|
});
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
||||||
|
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
||||||
|
currentConfig.bookmark_ids.push(bookmarkId);
|
||||||
|
} else if (!checked) {
|
||||||
|
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function(id) {
|
||||||
|
return id !== bookmarkId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
function saveBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
const payload = {
|
||||||
|
remote: rigId,
|
||||||
|
enabled: !!document.getElementById("background-decode-enabled").checked,
|
||||||
|
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : []
|
||||||
|
};
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
apiPutConfig(rigId, payload).then(function(saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode saved.");
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast("Save failed: " + err.message, true);
|
||||||
|
}).finally(function() {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function resetBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
|
||||||
|
apiResetConfig(rigId).then(function(saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode reset.");
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast("Reset failed: " + err.message, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function startStatusPolling() {
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
statusInterval = setInterval(pollBackgroundDecodeStatus, 15e3);
|
||||||
|
}
|
||||||
|
function pollBackgroundDecodeStatus() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
apiGetStatus(rigId).then(renderStatus).catch(function() {
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function renderStatus(status) {
|
||||||
|
const card = document.getElementById("background-decode-status-card");
|
||||||
|
if (!card) return;
|
||||||
|
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
||||||
|
if (!entries.length) {
|
||||||
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
if (status.active_rig) {
|
||||||
|
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||||
|
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
|
} else {
|
||||||
|
summary.push("This rig is not currently selected for audio.");
|
||||||
|
}
|
||||||
|
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||||
|
html += '<div class="bgd-status-list">';
|
||||||
|
entries.forEach(function(entry) {
|
||||||
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
|
const parts = [];
|
||||||
|
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
|
if (entry.mode) parts.push(entry.mode);
|
||||||
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
|
}
|
||||||
|
html += '<div class="bgd-status-row"><div><div class="bgd-status-name">' + escHtml(name) + '</div><div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div></div><div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '"><svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' + escHtml(prettyState(entry.state)) + "</div></div>";
|
||||||
|
});
|
||||||
|
html += "</div>";
|
||||||
|
card.innerHTML = html;
|
||||||
|
}
|
||||||
|
function prettyState(state) {
|
||||||
|
switch (state) {
|
||||||
|
case "active":
|
||||||
|
return "✓ Active";
|
||||||
|
case "out_of_span":
|
||||||
|
return "△ Out of span";
|
||||||
|
case "waiting_for_spectrum":
|
||||||
|
return "△ Waiting";
|
||||||
|
case "waiting_for_user":
|
||||||
|
return "△ No user";
|
||||||
|
case "missing_bookmark":
|
||||||
|
return "✗ Missing";
|
||||||
|
case "no_supported_decoders":
|
||||||
|
return "✗ Unsupported";
|
||||||
|
case "disabled":
|
||||||
|
return "△ Disabled";
|
||||||
|
case "handled_by_scheduler":
|
||||||
|
return "△ Scheduler";
|
||||||
|
case "scheduler_has_control":
|
||||||
|
return "△ Scheduler";
|
||||||
|
case "handled_by_virtual_channel":
|
||||||
|
return "△ VChan";
|
||||||
|
default:
|
||||||
|
return "△ Inactive";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function setCheckbox(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.checked = !!value;
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return hz + " Hz";
|
||||||
|
}
|
||||||
|
function escHtml(value) {
|
||||||
|
return String(value == null ? "" : value).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function markBgdDirty() {
|
||||||
|
if (bgdDirty) return;
|
||||||
|
bgdDirty = true;
|
||||||
|
var btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
|
}
|
||||||
|
function clearBgdDirty() {
|
||||||
|
bgdDirty = false;
|
||||||
|
var btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.remove("sch-dirty");
|
||||||
|
}
|
||||||
|
function showToast(msg, isError) {
|
||||||
|
const el = document.getElementById("background-decode-toast");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
||||||
|
el.style.display = "block";
|
||||||
|
setTimeout(function() {
|
||||||
|
el.style.display = "none";
|
||||||
|
}, 3e3);
|
||||||
|
}
|
||||||
|
function selectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
var ids = supportedBookmarks().map(function(bm) {
|
||||||
|
return bm.id;
|
||||||
|
});
|
||||||
|
currentConfig.bookmark_ids = ids;
|
||||||
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
function deselectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
currentConfig.bookmark_ids = [];
|
||||||
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
function wireBackgroundDecodeEvents() {
|
||||||
|
const filterInput = document.getElementById("bgd-bookmark-filter");
|
||||||
|
if (filterInput && !filterInput._wired) {
|
||||||
|
filterInput._wired = true;
|
||||||
|
filterInput.addEventListener("input", function() {
|
||||||
|
renderBookmarkChecklist(filterInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const enabledCb = document.getElementById("background-decode-enabled");
|
||||||
|
if (enabledCb && !enabledCb._wired) {
|
||||||
|
enabledCb._wired = true;
|
||||||
|
enabledCb.addEventListener("change", function() {
|
||||||
|
markBgdDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
|
if (selectAllBtn && !selectAllBtn._wired) {
|
||||||
|
selectAllBtn._wired = true;
|
||||||
|
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||||
|
}
|
||||||
|
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
||||||
|
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||||
|
deselectAllBtn._wired = true;
|
||||||
|
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (saveBtn && !saveBtn._wired) {
|
||||||
|
saveBtn._wired = true;
|
||||||
|
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||||
|
}
|
||||||
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
|
if (resetBtn && !resetBtn._wired) {
|
||||||
|
resetBtn._wired = true;
|
||||||
|
resetBtn.addEventListener("click", resetBackgroundDecode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.initBackgroundDecode = initBackgroundDecode;
|
||||||
|
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||||
|
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,647 @@
|
|||||||
|
"use strict";
|
||||||
|
let bmScope = "general";
|
||||||
|
function bmScopeParam(prefix, scope) {
|
||||||
|
const sep = prefix ? "&" : "?";
|
||||||
|
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||||
|
}
|
||||||
|
var bmList = [];
|
||||||
|
var bmRevision = 0;
|
||||||
|
var bmOverlayList = [];
|
||||||
|
var bmOverlayRevision = 0;
|
||||||
|
let bmFilteredList = [];
|
||||||
|
let bmEditId = null;
|
||||||
|
let bmEditScope = null;
|
||||||
|
let bmCurrentPage = 1;
|
||||||
|
const BM_PAGE_SIZE = 25;
|
||||||
|
const bmSelected = /* @__PURE__ */ new Set();
|
||||||
|
function bmFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return hz + " Hz";
|
||||||
|
}
|
||||||
|
function bmEsc(str) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
function bmCanControl() {
|
||||||
|
return typeof authEnabled !== "undefined" && !authEnabled || typeof authRole !== "undefined" && authRole === "control";
|
||||||
|
}
|
||||||
|
function bmSyncAccess() {
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const addBtn = document.getElementById("bm-add-btn");
|
||||||
|
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||||
|
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
}
|
||||||
|
function bmListScope() {
|
||||||
|
const rig = typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null;
|
||||||
|
return rig || "general";
|
||||||
|
}
|
||||||
|
async function bmFetchOverlay() {
|
||||||
|
const overlayScope = bmListScope();
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
bmOverlayList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch overlay bookmarks:", e);
|
||||||
|
bmOverlayList = [];
|
||||||
|
}
|
||||||
|
bmOverlayRevision++;
|
||||||
|
if (typeof window.syncBookmarkMapLocators === "function") {
|
||||||
|
window.syncBookmarkMapLocators(bmOverlayList);
|
||||||
|
}
|
||||||
|
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
async function bmFetch(categoryFilter) {
|
||||||
|
let url = "/bookmarks";
|
||||||
|
let hasQuery = false;
|
||||||
|
if (categoryFilter && categoryFilter !== "") {
|
||||||
|
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||||
|
hasQuery = true;
|
||||||
|
}
|
||||||
|
url += bmScopeParam(hasQuery);
|
||||||
|
const overlayPromise = bmFetchOverlay();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
bmList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch bookmarks:", e);
|
||||||
|
bmList = [];
|
||||||
|
}
|
||||||
|
bmRevision++;
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
bmSyncAccess();
|
||||||
|
bmApplyFilters();
|
||||||
|
bmRefreshCategoryFilter(categoryFilter);
|
||||||
|
await overlayPromise;
|
||||||
|
}
|
||||||
|
function bmApplyFilters() {
|
||||||
|
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||||
|
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||||
|
let filtered = modeFilter ? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter) : bmList;
|
||||||
|
filtered = text ? filtered.filter(
|
||||||
|
(bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text)
|
||||||
|
) : filtered;
|
||||||
|
bmFilteredList = filtered;
|
||||||
|
bmCurrentPage = 1;
|
||||||
|
bmRender(filtered);
|
||||||
|
}
|
||||||
|
async function bmRefreshCategoryFilter(keepValue) {
|
||||||
|
const sel = document.getElementById("bm-category-filter");
|
||||||
|
const modeSel = document.getElementById("bm-mode-filter");
|
||||||
|
if (!sel && !modeSel) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const all = await resp.json();
|
||||||
|
if (sel) {
|
||||||
|
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
cats.forEach((cat) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = cat;
|
||||||
|
opt.textContent = cat;
|
||||||
|
sel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||||
|
}
|
||||||
|
if (modeSel) {
|
||||||
|
const keepMode = modeSel.value;
|
||||||
|
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||||
|
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||||
|
modes.forEach((mode) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = mode;
|
||||||
|
opt.textContent = mode;
|
||||||
|
modeSel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmRender(list) {
|
||||||
|
const tbody = document.getElementById("bm-tbody");
|
||||||
|
const emptyEl = document.getElementById("bm-empty");
|
||||||
|
const paginatorEl = document.getElementById("bm-paginator");
|
||||||
|
const pageSummaryEl = document.getElementById("bm-page-summary");
|
||||||
|
const pageIndicatorEl = document.getElementById("bm-page-indicator");
|
||||||
|
const prevBtn = document.getElementById("bm-page-prev");
|
||||||
|
const nextBtn = document.getElementById("bm-page-next");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
if (list.length === 0) {
|
||||||
|
if (emptyEl) emptyEl.style.display = "";
|
||||||
|
if (paginatorEl) paginatorEl.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyEl) emptyEl.style.display = "none";
|
||||||
|
const canControl = bmCanControl();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
|
||||||
|
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
|
||||||
|
bmCurrentPage = page;
|
||||||
|
const startIndex = (page - 1) * BM_PAGE_SIZE;
|
||||||
|
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
|
||||||
|
const pageItems = list.slice(startIndex, endIndex);
|
||||||
|
const showScope = bmScope !== "general";
|
||||||
|
pageItems.forEach((bm) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.bmId = bm.id;
|
||||||
|
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||||
|
const locatorCell = bm.locator || "--";
|
||||||
|
const catCell = bm.category || "Uncategorised";
|
||||||
|
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||||
|
const commentCell = bm.comment || "";
|
||||||
|
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||||
|
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
|
||||||
|
tr.innerHTML = `<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td><td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td><td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td><td class="bm-col-mode">${bmEsc(bm.mode)}</td><td class="bm-col-bw">${bwCell}</td><td class="bm-col-loc">${bmEsc(locatorCell)}</td><td class="bm-col-cat">${bmEsc(catCell)}</td><td class="bm-col-dec">${bmEsc(decoderCell)}</td><td class="bm-col-cmt">${bmEsc(commentCell)}</td><td class="bm-col-act"><button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` + (canControl ? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button><button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>` : "") + `</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||||
|
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||||
|
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
|
||||||
|
if (prevBtn) prevBtn.disabled = page <= 1;
|
||||||
|
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||||
|
}
|
||||||
|
function bmChangePage(delta) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||||
|
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||||
|
if (nextPage === bmCurrentPage) return;
|
||||||
|
bmCurrentPage = nextPage;
|
||||||
|
bmRender(bmFilteredList);
|
||||||
|
}
|
||||||
|
function bmReadDecoders() {
|
||||||
|
return (window.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => document.getElementById("bm-dec-" + d.id)?.checked).map((d) => d.id);
|
||||||
|
}
|
||||||
|
function bmWriteDecoders(decoders) {
|
||||||
|
const set = new Set(decoders || []);
|
||||||
|
(window.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
|
const el = document.getElementById("bm-dec-" + d.id);
|
||||||
|
if (el) el.checked = set.has(d.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bmBuildDecoderCheckboxes() {
|
||||||
|
const container = document.getElementById("bm-decoder-checkboxes");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
(window.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "bm-decoder-check";
|
||||||
|
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function bmOpenForm(bm) {
|
||||||
|
const wrap = document.getElementById("bm-form-wrap");
|
||||||
|
if (!wrap) return;
|
||||||
|
bmEditId = bm ? bm.id : null;
|
||||||
|
bmEditScope = bm ? bm.scope || bmScope : null;
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
document.getElementById("bm-id").value = bm ? bm.id : "";
|
||||||
|
document.getElementById("bm-name").value = bm ? bm.name : "";
|
||||||
|
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
||||||
|
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||||
|
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
||||||
|
document.getElementById("bm-locator").value = bm ? bm.locator || "" : "";
|
||||||
|
document.getElementById("bm-category-input").value = bm ? bm.category || "" : "";
|
||||||
|
document.getElementById("bm-comment").value = bm ? bm.comment || "" : "";
|
||||||
|
bmWriteDecoders(bm ? bm.decoders : []);
|
||||||
|
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
wrap.style.display = "flex";
|
||||||
|
document.getElementById("bm-name").focus();
|
||||||
|
}
|
||||||
|
function bmCloseForm() {
|
||||||
|
const wrap = document.getElementById("bm-form-wrap");
|
||||||
|
if (wrap) wrap.style.display = "none";
|
||||||
|
bmEditId = null;
|
||||||
|
}
|
||||||
|
function bmPrefillFromStatus() {
|
||||||
|
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
||||||
|
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
||||||
|
}
|
||||||
|
if (typeof lastModeName === "string" && lastModeName) {
|
||||||
|
document.getElementById("bm-mode").value = lastModeName;
|
||||||
|
}
|
||||||
|
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
||||||
|
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
||||||
|
}
|
||||||
|
const activeDecoders = (window.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => {
|
||||||
|
const btn = document.getElementById(d.id + "-decode-toggle-btn");
|
||||||
|
return btn && btn.dataset.enabled === "true";
|
||||||
|
}).map((d) => d.id);
|
||||||
|
bmWriteDecoders(activeDecoders);
|
||||||
|
}
|
||||||
|
async function bmSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById("bm-id").value;
|
||||||
|
const name = document.getElementById("bm-name").value.trim();
|
||||||
|
const freqStr = document.getElementById("bm-freq").value;
|
||||||
|
const freq_hz = parseInt(freqStr, 10);
|
||||||
|
const mode = document.getElementById("bm-mode").value.trim();
|
||||||
|
const bwStr = document.getElementById("bm-bw").value;
|
||||||
|
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||||
|
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
|
||||||
|
const category = document.getElementById("bm-category-input").value.trim();
|
||||||
|
const comment = document.getElementById("bm-comment").value.trim();
|
||||||
|
const decoders = bmReadDecoders();
|
||||||
|
const formError = document.getElementById("bm-form-error");
|
||||||
|
if (formError) formError.textContent = "";
|
||||||
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
|
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
||||||
|
const invalid = !name ? document.getElementById("bm-name") : !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
|
||||||
|
invalid?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
freq_hz,
|
||||||
|
mode,
|
||||||
|
bandwidth_hz,
|
||||||
|
locator: locator || null,
|
||||||
|
category,
|
||||||
|
comment,
|
||||||
|
decoders
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (id) {
|
||||||
|
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resp = await fetch("/bookmarks" + bmScopeParam(false), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
if (resp.status === 409) {
|
||||||
|
throw new Error("A bookmark for that frequency already exists.");
|
||||||
|
}
|
||||||
|
throw new Error(text || "HTTP " + resp.status);
|
||||||
|
}
|
||||||
|
bmCloseForm();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save bookmark:", err);
|
||||||
|
if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
|
||||||
|
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmDelete(id) {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm ? bm.scope : void 0;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||||
|
method: "DELETE"
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmark:", err);
|
||||||
|
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmApply(bm) {
|
||||||
|
try {
|
||||||
|
if (typeof modeEl !== "undefined" && modeEl) {
|
||||||
|
modeEl.value = String(bm.mode || "").toUpperCase();
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
if (typeof currentBandwidthHz !== "undefined") {
|
||||||
|
currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
}
|
||||||
|
window.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
if (typeof syncBandwidthInput === "function") {
|
||||||
|
syncBandwidthInput(bm.bandwidth_hz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof applyLocalTunedFrequency === "function") {
|
||||||
|
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||||
|
++_freqOptimisticSeq;
|
||||||
|
_freqOptimisticHz = bm.freq_hz;
|
||||||
|
}
|
||||||
|
applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
|
}
|
||||||
|
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
|
||||||
|
scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
const tunePromise = (async () => {
|
||||||
|
if (typeof vchanTakeSchedulerControl === "function") {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
}
|
||||||
|
const onVirtual = typeof vchanInterceptMode === "function" && await vchanInterceptMode(bm.mode);
|
||||||
|
if (!onVirtual) {
|
||||||
|
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function" && await vchanInterceptBandwidth(bm.bandwidth_hz);
|
||||||
|
if (!bwHandledByVchan) {
|
||||||
|
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof setRigFrequency === "function") {
|
||||||
|
await setRigFrequency(bm.freq_hz);
|
||||||
|
} else {
|
||||||
|
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
|
const allToggleDecoders = (window.decoderRegistry || []).filter(
|
||||||
|
(d) => d.activation === "toggle"
|
||||||
|
);
|
||||||
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
|
let statusUrl = "/status";
|
||||||
|
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
|
||||||
|
}
|
||||||
|
const statusResp = await fetch(statusUrl);
|
||||||
|
if (!statusResp.ok) return;
|
||||||
|
const st = await statusResp.json();
|
||||||
|
const toggles = [];
|
||||||
|
for (const d of allToggleDecoders) {
|
||||||
|
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
|
const currentlyOn = !!st[statusKey];
|
||||||
|
const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp);
|
||||||
|
let wanted;
|
||||||
|
if (!compatible) {
|
||||||
|
wanted = false;
|
||||||
|
} else if (hasDecoders) {
|
||||||
|
wanted = bm.decoders.includes(d.id);
|
||||||
|
} else {
|
||||||
|
wanted = currentlyOn;
|
||||||
|
}
|
||||||
|
if (wanted !== currentlyOn) {
|
||||||
|
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toggles.length) await Promise.all(toggles);
|
||||||
|
})() : Promise.resolve();
|
||||||
|
Promise.all([tunePromise, decoderPromise]).catch(
|
||||||
|
(err) => console.error("Bookmark apply background error:", err)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to apply bookmark:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmUpdateSelectionUi() {
|
||||||
|
const count = bmSelected.size;
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const visible = count > 0 && canCtrl;
|
||||||
|
const btn = document.getElementById("bm-del-selected-btn");
|
||||||
|
const countEl = document.getElementById("bm-del-selected-count");
|
||||||
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
|
if (countEl) countEl.textContent = count;
|
||||||
|
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
||||||
|
const moveCountEl = document.getElementById("bm-move-selected-count");
|
||||||
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
|
if (moveCountEl) moveCountEl.textContent = count;
|
||||||
|
if (visible) bmPopulateMoveTarget();
|
||||||
|
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||||
|
if (selectAllBtn && bmCanControl()) {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmPopulateMoveTarget() {
|
||||||
|
const sel = document.getElementById("bm-move-target");
|
||||||
|
if (!sel) return;
|
||||||
|
const rigIds = typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds) ? lastRigIds : [];
|
||||||
|
const displayNames = typeof lastRigDisplayNames !== "undefined" ? lastRigDisplayNames : {};
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (bmScope !== "general") {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "general";
|
||||||
|
opt.textContent = "General";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
if (id === bmScope) return;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||||
|
sel.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function bmMoveSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const target = document.getElementById("bm-move-target")?.value;
|
||||||
|
if (!target) return;
|
||||||
|
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
|
if (!await window.trxUi.confirm({
|
||||||
|
title: "Move selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
||||||
|
confirmLabel: "Move",
|
||||||
|
danger: false
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
if (scope === target) continue;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(
|
||||||
|
([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds, to: target })
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move bookmarks:", err);
|
||||||
|
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmSyncSelectAllCheckbox() {
|
||||||
|
const selectAll = document.getElementById("bm-select-all");
|
||||||
|
if (!selectAll) return;
|
||||||
|
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
selectAll.checked = false;
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||||
|
selectAll.checked = checkedCount === checkboxes.length;
|
||||||
|
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||||
|
}
|
||||||
|
async function bmDeleteSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (!await window.trxUi.confirm({
|
||||||
|
title: "Delete selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
||||||
|
confirmLabel: "Delete"
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(
|
||||||
|
([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds })
|
||||||
|
}).then((r) => {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
})
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmarks:", err);
|
||||||
|
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function bmPopulateScopePicker() {
|
||||||
|
const picker = document.getElementById("bm-scope-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
const rigIds = typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds) ? lastRigIds : [];
|
||||||
|
const displayNames = typeof lastRigDisplayNames !== "undefined" ? lastRigDisplayNames : {};
|
||||||
|
const prev = picker.value;
|
||||||
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
picker.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && (prev === "general" || rigIds.includes(prev))) {
|
||||||
|
picker.value = prev;
|
||||||
|
} else {
|
||||||
|
picker.value = "general";
|
||||||
|
}
|
||||||
|
bmScope = picker.value;
|
||||||
|
}
|
||||||
|
(function initBookmarks() {
|
||||||
|
bmSyncAccess();
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
if (typeof window.onDecoderRegistryReady === "function") {
|
||||||
|
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
|
}
|
||||||
|
bmPopulateScopePicker();
|
||||||
|
const scopePicker = document.getElementById("bm-scope-picker");
|
||||||
|
if (scopePicker) {
|
||||||
|
scopePicker.addEventListener("change", (e) => {
|
||||||
|
bmScope = e.target.value;
|
||||||
|
bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
||||||
|
if (!btn) return;
|
||||||
|
bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
});
|
||||||
|
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
||||||
|
bmOpenForm(null);
|
||||||
|
bmPrefillFromStatus();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
||||||
|
bmFetch(e.target.value);
|
||||||
|
});
|
||||||
|
document.getElementById("bm-mode-filter").addEventListener("change", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-text-filter").addEventListener("input", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-page-prev").addEventListener("click", () => {
|
||||||
|
bmChangePage(-1);
|
||||||
|
});
|
||||||
|
document.getElementById("bm-page-next").addEventListener("click", () => {
|
||||||
|
bmChangePage(1);
|
||||||
|
});
|
||||||
|
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
||||||
|
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
const formWrap = document.getElementById("bm-form-wrap");
|
||||||
|
if (formWrap) {
|
||||||
|
formWrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === formWrap) bmCloseForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
|
||||||
|
bmCloseForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) bmSelected.add(cb.dataset.bmId);
|
||||||
|
else bmSelected.delete(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
if (allSelected) {
|
||||||
|
bmSelected.clear();
|
||||||
|
} else {
|
||||||
|
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||||
|
}
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = bmSelected.has(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
|
bmDeleteSelected();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
|
bmMoveSelected();
|
||||||
|
});
|
||||||
|
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||||
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
|
if (checkbox) {
|
||||||
|
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
||||||
|
else bmSelected.delete(checkbox.dataset.bmId);
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||||
|
const editBtn = e.target.closest(".bm-edit-btn");
|
||||||
|
const delBtn = e.target.closest(".bm-del-btn");
|
||||||
|
if (tuneBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||||
|
if (bm) await bmApply(bm);
|
||||||
|
} else if (editBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||||
|
if (bm) bmOpenForm(bm);
|
||||||
|
} else if (delBtn) {
|
||||||
|
await bmDelete(delBtn.dataset.bmId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bmFetch("");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use strict";
|
||||||
|
const cwStatusEl = document.getElementById("cw-status");
|
||||||
|
const cwOutputEl = document.getElementById("cw-output");
|
||||||
|
const cwAutoInput = document.getElementById("cw-auto");
|
||||||
|
const cwWpmInput = document.getElementById("cw-wpm");
|
||||||
|
const cwToneInput = document.getElementById("cw-tone");
|
||||||
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
|
const cwToneGl = typeof createTrxWebGlRenderer === "function" ? createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
||||||
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
|
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
|
const CW_MAX_LINES = 200;
|
||||||
|
const CW_TONE_MIN_HZ = 100;
|
||||||
|
const CW_TONE_MAX_HZ = 1e4;
|
||||||
|
const CW_WPM_MIN = 5;
|
||||||
|
const CW_WPM_MAX = 40;
|
||||||
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
const CW_BAR_LINE_GAP_MS = 5e3;
|
||||||
|
let cwLastAppendTime = 0;
|
||||||
|
let cwTonePickerRaf = null;
|
||||||
|
let cwBarHistory = [];
|
||||||
|
let cwBarCurrentLine = null;
|
||||||
|
let cwBarDismissedAtMs = 0;
|
||||||
|
let cwAutoLocalOverride = null;
|
||||||
|
function applyCwAutoUi(enabled) {
|
||||||
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
|
if (cwWpmInput) {
|
||||||
|
cwWpmInput.disabled = enabled;
|
||||||
|
cwWpmInput.readOnly = enabled;
|
||||||
|
}
|
||||||
|
if (cwToneInput) {
|
||||||
|
cwToneInput.disabled = enabled;
|
||||||
|
cwToneInput.readOnly = enabled;
|
||||||
|
}
|
||||||
|
if (cwTonePickerEl) {
|
||||||
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
window.applyCwAutoUiFromServer = function(enabled) {
|
||||||
|
if (cwAutoLocalOverride !== null) return;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
};
|
||||||
|
function cwBarFlushCurrentLine() {
|
||||||
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
|
}
|
||||||
|
cwBarCurrentLine = null;
|
||||||
|
}
|
||||||
|
function updateCwBar() {
|
||||||
|
if (!cwBarOverlay) return;
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
||||||
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearCwBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearCwBar();}" aria-label="Clear CW overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button></span></div>`;
|
||||||
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
|
const meta = [
|
||||||
|
line.wpm ? `${line.wpm} WPM` : null,
|
||||||
|
line.tone_hz ? `${line.tone_hz} Hz` : null
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") + `</div></div>`;
|
||||||
|
}
|
||||||
|
cwBarOverlay.innerHTML = html;
|
||||||
|
cwBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateCwBar = updateCwBar;
|
||||||
|
window.clearCwBar = function() {
|
||||||
|
window.resetCwHistoryView();
|
||||||
|
};
|
||||||
|
window.closeCwBar = function() {
|
||||||
|
cwBarDismissedAtMs = Date.now();
|
||||||
|
if (cwBarOverlay) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function clampCwWpm(wpm) {
|
||||||
|
const numeric = Number(wpm);
|
||||||
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
|
}
|
||||||
|
function clampCwTone(tone) {
|
||||||
|
const numeric = Number(tone);
|
||||||
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
|
}
|
||||||
|
function currentCwToneRange() {
|
||||||
|
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
||||||
|
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
||||||
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const lowerSideband = mode === "CWR";
|
||||||
|
const upperSideband = mode === "CW";
|
||||||
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
|
if (toneMaxHz < toneMinHz) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tunedHz,
|
||||||
|
bandwidthHz,
|
||||||
|
toneMinHz,
|
||||||
|
toneMaxHz,
|
||||||
|
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||||
|
lowerSideband,
|
||||||
|
mode
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function cwToneToRfHz(range, toneHz) {
|
||||||
|
if (!range) return NaN;
|
||||||
|
return range.lowerSideband ? range.tunedHz - toneHz : range.tunedHz + toneHz;
|
||||||
|
}
|
||||||
|
function toneClampForRange(tone, range) {
|
||||||
|
const clamped = clampCwTone(tone);
|
||||||
|
if (!range) return clamped;
|
||||||
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
|
}
|
||||||
|
function ensureCwToneCanvasResolution() {
|
||||||
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
|
const cssWidth = Math.round(rect.width);
|
||||||
|
const cssHeight = Math.round(rect.height);
|
||||||
|
if (cssWidth < 8 || cssHeight < 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
|
}
|
||||||
|
function drawCwTonePicker() {
|
||||||
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
|
const width = cwToneCanvas.width;
|
||||||
|
const height = cwToneCanvas.height;
|
||||||
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
|
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
||||||
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
|
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
||||||
|
}
|
||||||
|
const bins = window.lastSpectrumData.bins;
|
||||||
|
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
||||||
|
const centerHz = Number(window.lastSpectrumData.center_hz);
|
||||||
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
|
const tones = new Array(width).fill(-140);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
|
const rfHz = cwToneToRfHz(range, toneHz);
|
||||||
|
const idx = Math.max(0, Math.min(maxIdx, Math.round((rfHz - fullLoHz) / sampleRate * maxIdx)));
|
||||||
|
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
||||||
|
tones[x] = power;
|
||||||
|
}
|
||||||
|
const smoothed = new Array(width).fill(-140);
|
||||||
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
|
if (i < 0 || i >= width) continue;
|
||||||
|
sum += tones[i];
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
smoothed[x] = count > 0 ? sum / count : tones[x];
|
||||||
|
}
|
||||||
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
|
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||||
|
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||||
|
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||||
|
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||||
|
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||||
|
const yForDb = (db) => {
|
||||||
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
|
return Math.round((1 - n) * (height - 1));
|
||||||
|
};
|
||||||
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
|
const parseColor = typeof window.trxParseCssColor === "function" ? window.trxParseCssColor : null;
|
||||||
|
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
|
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
const hGridCount = 4;
|
||||||
|
const gridSegments = [];
|
||||||
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
|
const y = Math.round(i / (hGridCount + 1) * (height - 1));
|
||||||
|
gridSegments.push(0, y, width, y);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||||
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1e3 ? 100 : 200;
|
||||||
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
|
const tickSegments = [];
|
||||||
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
|
tickSegments.push(x, 0, x, height);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
const linePoints = [];
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
linePoints.push(x, yForDb(smoothed[x]));
|
||||||
|
}
|
||||||
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
||||||
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
if (cwAutoInput?.checked) {
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
const clamped = toneClampForRange(tone, range);
|
||||||
|
if (cwToneInput && syncInput) {
|
||||||
|
cwToneInput.value = clamped;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW tone set failed", e);
|
||||||
|
}
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
if (cwAutoInput) {
|
||||||
|
cwAutoInput.addEventListener("change", async () => {
|
||||||
|
const enabled = cwAutoInput.checked;
|
||||||
|
cwAutoLocalOverride = enabled;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
try {
|
||||||
|
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
|
drawCwTonePicker();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW auto toggle failed", e);
|
||||||
|
} finally {
|
||||||
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cwWpmInput) {
|
||||||
|
cwWpmInput.addEventListener("change", async () => {
|
||||||
|
if (cwAutoInput && cwAutoInput.checked) return;
|
||||||
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
|
cwWpmInput.value = wpm;
|
||||||
|
try {
|
||||||
|
await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW WPM set failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cwToneInput) {
|
||||||
|
cwToneInput.addEventListener("change", async () => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
await setCwTone(cwToneInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (cwToneCanvas) {
|
||||||
|
cwToneCanvas.addEventListener("click", async (event) => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
if (!range) return;
|
||||||
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
|
await setCwTone(tone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.resetCwHistoryView = function() {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
cwBarHistory = [];
|
||||||
|
cwBarCurrentLine = null;
|
||||||
|
updateCwBar();
|
||||||
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_cw_decode");
|
||||||
|
window.resetCwHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.onServerCw = function(evt) {
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "cw-line";
|
||||||
|
cwOutputEl.appendChild(line);
|
||||||
|
}
|
||||||
|
cwLastAppendTime = now;
|
||||||
|
const lastLine = cwOutputEl.lastElementChild;
|
||||||
|
if (lastLine) {
|
||||||
|
lastLine.textContent += evt.text;
|
||||||
|
}
|
||||||
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
|
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
||||||
|
}
|
||||||
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
if (evt.text) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (evt.text === "\n") {
|
||||||
|
cwBarFlushCurrentLine();
|
||||||
|
} else {
|
||||||
|
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
cwBarCurrentLine.text += evt.text;
|
||||||
|
cwBarCurrentLine.lastMs = now;
|
||||||
|
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||||
|
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
|
}
|
||||||
|
updateCwBar();
|
||||||
|
}
|
||||||
|
if (cwSignalIndicator) {
|
||||||
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
|
}
|
||||||
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
|
cwWpmInput.value = clampCwWpm(evt.wpm);
|
||||||
|
}
|
||||||
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
|
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cwTonePickerRaf != null) return;
|
||||||
|
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||||
|
cwTonePickerRaf = null;
|
||||||
|
drawCwTonePicker();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
window.restoreCwHistory = function(events) {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
for (const evt of events) {
|
||||||
|
window.onServerCw(evt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||||
|
});
|
||||||
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||||
|
updateCwBar();
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
"use strict";
|
||||||
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||||
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
|
const offset = state.offset;
|
||||||
|
if (additional < 24) return additional;
|
||||||
|
if (additional === 24) {
|
||||||
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 1;
|
||||||
|
return bytes[offset];
|
||||||
|
}
|
||||||
|
if (additional === 25) {
|
||||||
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 2;
|
||||||
|
return view.getUint16(offset);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 4;
|
||||||
|
return view.getUint32(offset);
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getBigUint64(offset);
|
||||||
|
state.offset += 8;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR additional info");
|
||||||
|
}
|
||||||
|
function decodeCborFloat16(bits) {
|
||||||
|
const sign = bits & 32768 ? -1 : 1;
|
||||||
|
const exponent = bits >> 10 & 31;
|
||||||
|
const fraction = bits & 1023;
|
||||||
|
if (exponent === 0) {
|
||||||
|
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
||||||
|
}
|
||||||
|
if (exponent === 31) {
|
||||||
|
return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||||
|
}
|
||||||
|
return sign * Math.pow(2, exponent - 15) * (1 + fraction / 1024);
|
||||||
|
}
|
||||||
|
function decodeCborItem(view, bytes, state) {
|
||||||
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const initial = bytes[state.offset++];
|
||||||
|
const major = initial >> 5;
|
||||||
|
const additional = initial & 31;
|
||||||
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 2) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.slice(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return Array.from(chunk);
|
||||||
|
}
|
||||||
|
if (major === 3) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
if (major === 4) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const items = new Array(length);
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
items[i] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
if (major === 5) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const value = {};
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const key = decodeCborItem(view, bytes, state);
|
||||||
|
value[String(key)] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (major === 6) {
|
||||||
|
decodeCborUint(view, bytes, state, additional);
|
||||||
|
return decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
if (major === 7) {
|
||||||
|
if (additional === 20) return false;
|
||||||
|
if (additional === 21) return true;
|
||||||
|
if (additional === 22) return null;
|
||||||
|
if (additional === 23) return void 0;
|
||||||
|
if (additional === 25) {
|
||||||
|
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const bits = view.getUint16(state.offset);
|
||||||
|
state.offset += 2;
|
||||||
|
return decodeCborFloat16(bits);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat32(state.offset);
|
||||||
|
state.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat64(state.offset);
|
||||||
|
state.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR major type");
|
||||||
|
}
|
||||||
|
function decodeCborPayload(buffer) {
|
||||||
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const state = { offset: 0 };
|
||||||
|
const value = decodeCborItem(view, bytes, state);
|
||||||
|
if (state.offset !== bytes.length) {
|
||||||
|
throw new Error("Unexpected trailing bytes in decode history payload");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||||
|
self.postMessage({ type: "status", phase: "fetching" });
|
||||||
|
const resp = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
||||||
|
const payload = await resp.arrayBuffer();
|
||||||
|
if (!payload || payload.byteLength === 0) {
|
||||||
|
self.postMessage({ type: "start", total: 0 });
|
||||||
|
self.postMessage({ type: "done", total: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.postMessage({ type: "status", phase: "decoding" });
|
||||||
|
const history = decodeCborPayload(payload);
|
||||||
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||||
|
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
||||||
|
return sum + items.length;
|
||||||
|
}, 0);
|
||||||
|
self.postMessage({ type: "start", total });
|
||||||
|
let processed = 0;
|
||||||
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||||
|
for (const kind of HISTORY_GROUP_KEYS) {
|
||||||
|
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
||||||
|
if (items.length === 0) continue;
|
||||||
|
for (let index = 0; index < items.length; index += safeLimit) {
|
||||||
|
const messages = items.slice(index, index + safeLimit);
|
||||||
|
processed += messages.length;
|
||||||
|
self.postMessage({
|
||||||
|
type: "group",
|
||||||
|
kind,
|
||||||
|
messages,
|
||||||
|
processed,
|
||||||
|
total
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.postMessage({ type: "done", total });
|
||||||
|
}
|
||||||
|
self.onmessage = (event) => {
|
||||||
|
const data = event?.data || {};
|
||||||
|
if (data?.type !== "fetch-history") return;
|
||||||
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit).catch((err) => {
|
||||||
|
self.postMessage({
|
||||||
|
type: "error",
|
||||||
|
message: err && err.message ? err.message : String(err || "unknown worker failure")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use strict";
|
||||||
|
function ft8RenderMessageFt2(message) {
|
||||||
|
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||||
|
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const ft2Status = document.getElementById("ft2-status");
|
||||||
|
const ft2PeriodEl = document.getElementById("ft2-period");
|
||||||
|
const ft2MessagesEl = document.getElementById("ft2-messages");
|
||||||
|
const ft2FilterInput = document.getElementById("ft2-filter");
|
||||||
|
const FT2_PERIOD_MS = 3750;
|
||||||
|
const FT2_MAX_DOM_ROWS = 200;
|
||||||
|
let ft2FilterText = "";
|
||||||
|
let ft2MessageHistory = [];
|
||||||
|
function currentFt2HistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneFt2MessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
|
||||||
|
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleFt2Ui(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleFt2HistoryRender() {
|
||||||
|
scheduleFt2Ui("ft2-history", () => renderFt2History());
|
||||||
|
}
|
||||||
|
function normalizeFt2DisplayFreqHz(freqHz) {
|
||||||
|
const rawHz = Number(freqHz);
|
||||||
|
if (!Number.isFinite(rawHz)) return null;
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) {
|
||||||
|
return baseHz + rawHz;
|
||||||
|
}
|
||||||
|
return rawHz;
|
||||||
|
}
|
||||||
|
function updateFt2PeriodTimer() {
|
||||||
|
if (!ft2PeriodEl) return;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1e3;
|
||||||
|
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
updateFt2PeriodTimer();
|
||||||
|
setInterval(updateFt2PeriodTimer, 250);
|
||||||
|
function renderFt2Row(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const rawMessage = (msg.message || "").toString();
|
||||||
|
row.dataset.message = rawMessage.toUpperCase();
|
||||||
|
row.dataset.decoder = "ft2";
|
||||||
|
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||||
|
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||||
|
const renderedMessage = ft8RenderMessageFt2(rawMessage);
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||||
|
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderFt2History() {
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
if (!ft2MessagesEl) return;
|
||||||
|
const filter = ft2FilterText;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let rendered = 0;
|
||||||
|
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
|
||||||
|
const msg = ft2MessageHistory[i];
|
||||||
|
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||||
|
fragment.appendChild(renderFt2Row(msg));
|
||||||
|
rendered++;
|
||||||
|
}
|
||||||
|
ft2MessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function addFt2Message(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
ft2MessageHistory.unshift(msg);
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft2");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt2HistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerFt2Message(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||||
|
const grids = locatorDetails.length > 0 ? locatorDetails.map((d) => d.grid) : typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : [];
|
||||||
|
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||||
|
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
locatorDetails,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||||
|
message: msg.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerFt2Batch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerFt2Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
ft2MessageHistory = normalized.concat(ft2MessageHistory);
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft2");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt2HistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreFt2History = function(messages) {
|
||||||
|
window.onServerFt2Batch(messages);
|
||||||
|
};
|
||||||
|
window.pruneFt2HistoryView = function() {
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
renderFt2History();
|
||||||
|
};
|
||||||
|
window.resetFt2HistoryView = function() {
|
||||||
|
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
||||||
|
ft2MessageHistory = [];
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
renderFt2History();
|
||||||
|
};
|
||||||
|
function buildFt2BarFrames() {
|
||||||
|
const cutoffMs = Date.now() - 15 * 60 * 1e3;
|
||||||
|
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||||
|
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return { count: 0, newestTsMs: 0, html: "" };
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (const msg of messages) {
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||||
|
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||||
|
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||||
|
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||||
|
const text = ft8RenderMessageFt2((msg.message || "").toString());
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: messages.length, newestTsMs, html };
|
||||||
|
}
|
||||||
|
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
|
||||||
|
if (ft2FilterInput) {
|
||||||
|
ft2FilterInput.addEventListener("input", () => {
|
||||||
|
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
||||||
|
renderFt2History();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
|
||||||
|
ft2DecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
|
||||||
|
await postPath("/toggle_ft2_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT2 toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ft2_decode");
|
||||||
|
window.resetFt2HistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT2 history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.onServerFt2 = function(msg) {
|
||||||
|
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||||
|
const next = normalizeServerFt2Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
addFt2Message(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"use strict";
|
||||||
|
function ft8RenderMessage(message) {
|
||||||
|
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||||
|
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
const ft4Status = document.getElementById("ft4-status");
|
||||||
|
const ft4PeriodEl = document.getElementById("ft4-period");
|
||||||
|
const ft4MessagesEl = document.getElementById("ft4-messages");
|
||||||
|
const ft4FilterInput = document.getElementById("ft4-filter");
|
||||||
|
const FT4_PERIOD_MS = 7500;
|
||||||
|
const FT4_MAX_DOM_ROWS = 200;
|
||||||
|
let ft4FilterText = "";
|
||||||
|
let ft4MessageHistory = [];
|
||||||
|
function currentFt4HistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneFt4MessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
|
||||||
|
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleFt4Ui(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleFt4HistoryRender() {
|
||||||
|
scheduleFt4Ui("ft4-history", () => renderFt4History());
|
||||||
|
}
|
||||||
|
function normalizeFt4DisplayFreqHz(freqHz) {
|
||||||
|
const rawHz = Number(freqHz);
|
||||||
|
if (!Number.isFinite(rawHz)) return null;
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) {
|
||||||
|
return baseHz + rawHz;
|
||||||
|
}
|
||||||
|
return rawHz;
|
||||||
|
}
|
||||||
|
function updateFt4PeriodTimer() {
|
||||||
|
if (!ft4PeriodEl) return;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1e3;
|
||||||
|
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
updateFt4PeriodTimer();
|
||||||
|
setInterval(updateFt4PeriodTimer, 250);
|
||||||
|
function renderFt4Row(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const rawMessage = (msg.message || "").toString();
|
||||||
|
row.dataset.message = rawMessage.toUpperCase();
|
||||||
|
row.dataset.decoder = "ft4";
|
||||||
|
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||||
|
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||||
|
const renderedMessage = ft8RenderMessage(rawMessage);
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||||
|
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderFt4History() {
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
if (!ft4MessagesEl) return;
|
||||||
|
const filter = ft4FilterText;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let rendered = 0;
|
||||||
|
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
|
||||||
|
const msg = ft4MessageHistory[i];
|
||||||
|
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||||
|
fragment.appendChild(renderFt4Row(msg));
|
||||||
|
rendered++;
|
||||||
|
}
|
||||||
|
ft4MessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function addFt4Message(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
ft4MessageHistory.unshift(msg);
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft4");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt4HistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerFt4Message(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||||
|
const grids = locatorDetails.length > 0 ? locatorDetails.map((d) => d.grid) : typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : [];
|
||||||
|
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||||
|
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
locatorDetails,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||||
|
message: msg.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerFt4Batch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerFt4Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
ft4MessageHistory = normalized.concat(ft4MessageHistory);
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft4");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt4HistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreFt4History = function(messages) {
|
||||||
|
window.onServerFt4Batch(messages);
|
||||||
|
};
|
||||||
|
window.pruneFt4HistoryView = function() {
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
renderFt4History();
|
||||||
|
};
|
||||||
|
window.resetFt4HistoryView = function() {
|
||||||
|
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
|
||||||
|
ft4MessageHistory = [];
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
renderFt4History();
|
||||||
|
};
|
||||||
|
function buildFt4BarFrames() {
|
||||||
|
const cutoffMs = Date.now() - 15 * 60 * 1e3;
|
||||||
|
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||||
|
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return { count: 0, newestTsMs: 0, html: "" };
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (const msg of messages) {
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||||
|
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||||
|
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||||
|
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||||
|
const text = ft8RenderMessage((msg.message || "").toString());
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: messages.length, newestTsMs, html };
|
||||||
|
}
|
||||||
|
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
|
||||||
|
if (ft4FilterInput) {
|
||||||
|
ft4FilterInput.addEventListener("input", () => {
|
||||||
|
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
|
||||||
|
renderFt4History();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
|
||||||
|
ft4DecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
|
||||||
|
await postPath("/toggle_ft4_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT4 toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ft4_decode");
|
||||||
|
window.resetFt4HistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT4 history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.onServerFt4 = function(msg) {
|
||||||
|
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||||
|
const next = normalizeServerFt4Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
addFt4Message(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
"use strict";
|
||||||
|
const ft8Status = document.getElementById("ft8-status");
|
||||||
|
const ft8PeriodEl = document.getElementById("ft8-period");
|
||||||
|
const ft8MessagesEl = document.getElementById("ft8-messages");
|
||||||
|
const ft8FilterInput = document.getElementById("ft8-filter");
|
||||||
|
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
|
||||||
|
const FT8_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
const FT8_PERIOD_SECONDS = 15;
|
||||||
|
const FT8_MAX_DOM_ROWS = 200;
|
||||||
|
const FT8_BAR_DECODER_LABELS = {
|
||||||
|
ft8: "FT8",
|
||||||
|
ft4: "FT4",
|
||||||
|
ft2: "FT2"
|
||||||
|
};
|
||||||
|
let ft8FilterText = "";
|
||||||
|
let ft8MessageHistory = [];
|
||||||
|
let ft8BarActiveDecoder = "ft8";
|
||||||
|
const ft8BarBuilders = {};
|
||||||
|
const ft8BarDismissedAtMsByDecoder = {
|
||||||
|
ft8: 0,
|
||||||
|
ft4: 0,
|
||||||
|
ft2: 0
|
||||||
|
};
|
||||||
|
function currentFt8HistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneFt8MessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
|
||||||
|
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleFt8Ui(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleFt8HistoryRender() {
|
||||||
|
scheduleFt8Ui("ft8-history", () => renderFt8History());
|
||||||
|
}
|
||||||
|
function scheduleFt8BarUpdate() {
|
||||||
|
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
|
||||||
|
}
|
||||||
|
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
|
||||||
|
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
|
||||||
|
ft8BarBuilders[decoder] = builder;
|
||||||
|
};
|
||||||
|
window.setFt8FamilyBarDecoder = function(decoder) {
|
||||||
|
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
|
||||||
|
ft8BarActiveDecoder = decoder;
|
||||||
|
scheduleFt8BarUpdate();
|
||||||
|
};
|
||||||
|
function normalizeFt8DisplayFreqHz(freqHz) {
|
||||||
|
const rawHz = Number(freqHz);
|
||||||
|
if (!Number.isFinite(rawHz)) return null;
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 1e5) {
|
||||||
|
return baseHz + rawHz;
|
||||||
|
}
|
||||||
|
return rawHz;
|
||||||
|
}
|
||||||
|
function fmtTime(tsMs) {
|
||||||
|
if (!tsMs) return "--:--:--";
|
||||||
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
function updateFt8PeriodTimer() {
|
||||||
|
if (!ft8PeriodEl) return;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1e3);
|
||||||
|
const remaining = FT8_PERIOD_SECONDS - nowSec % FT8_PERIOD_SECONDS;
|
||||||
|
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
updateFt8PeriodTimer();
|
||||||
|
setInterval(updateFt8PeriodTimer, 500);
|
||||||
|
function renderFt8Row(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const rawMessage = (msg.message || "").toString();
|
||||||
|
row.dataset.message = rawMessage.toUpperCase();
|
||||||
|
row.dataset.decoder = "ft8";
|
||||||
|
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||||
|
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||||
|
const renderedMessage = renderFt8Message(rawMessage);
|
||||||
|
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
applyFt8FilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderFt8History() {
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
if (!ft8MessagesEl) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
|
||||||
|
for (let i = 0; i < limit; i += 1) {
|
||||||
|
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
|
||||||
|
}
|
||||||
|
ft8MessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function addFt8Message(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
ft8MessageHistory.unshift(msg);
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
ft8BarActiveDecoder = "ft8";
|
||||||
|
scheduleFt8BarUpdate();
|
||||||
|
scheduleFt8HistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerFt8Message(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
||||||
|
const grids = locatorDetails.length > 0 ? locatorDetails.map((detail) => detail.grid) : ft8ExtractAllGrids(raw);
|
||||||
|
const station = ft8ExtractLikelyCallsign(raw);
|
||||||
|
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
locatorDetails,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||||
|
message: msg.message
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerFt8Batch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
ft8Status.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerFt8Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz,
|
||||||
|
locator_details: next.locatorDetails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
ft8MessageHistory = normalized.concat(ft8MessageHistory);
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
ft8BarActiveDecoder = "ft8";
|
||||||
|
scheduleFt8BarUpdate();
|
||||||
|
scheduleFt8HistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreFt8History = function(messages) {
|
||||||
|
window.onServerFt8Batch(messages);
|
||||||
|
};
|
||||||
|
window.pruneFt8HistoryView = function() {
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
updateFt8Bar();
|
||||||
|
renderFt8History();
|
||||||
|
};
|
||||||
|
function ft8BarRfText(msg) {
|
||||||
|
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||||
|
if (!Number.isFinite(displayFreqHz)) return null;
|
||||||
|
return `${displayFreqHz.toFixed(0)} Hz`;
|
||||||
|
}
|
||||||
|
function buildFt8BarFrames() {
|
||||||
|
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
|
||||||
|
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||||
|
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return { count: 0, newestTsMs: 0, html: "" };
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||||
|
const rf = ft8BarRfText(msg);
|
||||||
|
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||||
|
const text = ft8EscapeHtml((msg.message || "").toString());
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: messages.length, newestTsMs, html };
|
||||||
|
}
|
||||||
|
function updateFt8Bar() {
|
||||||
|
if (!ft8BarOverlay) return;
|
||||||
|
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
|
||||||
|
const decoder = ft8BarActiveDecoder;
|
||||||
|
const builder = ft8BarBuilders[decoder];
|
||||||
|
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
|
||||||
|
const result = typeof builder === "function" ? builder() : null;
|
||||||
|
const newestTsMs = Number(result?.newestTsMs) || 0;
|
||||||
|
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
|
||||||
|
ft8BarOverlay.style.display = "none";
|
||||||
|
ft8BarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
||||||
|
ft8BarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateFt8Bar = updateFt8Bar;
|
||||||
|
window.clearFt8Bar = function() {
|
||||||
|
const decoder = ft8BarActiveDecoder;
|
||||||
|
if (decoder === "ft4") {
|
||||||
|
window.resetFt4HistoryView?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decoder === "ft2") {
|
||||||
|
window.resetFt2HistoryView?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.resetFt8HistoryView?.();
|
||||||
|
};
|
||||||
|
window.closeFt8Bar = function() {
|
||||||
|
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
|
||||||
|
if (ft8BarOverlay) {
|
||||||
|
ft8BarOverlay.style.display = "none";
|
||||||
|
ft8BarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
|
||||||
|
function renderFt8Message(message) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
const ch = message[i];
|
||||||
|
if (ft8IsAlphaNum(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (ft8IsMaidenheadGridToken(grid)) {
|
||||||
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} else {
|
||||||
|
out += ft8EscapeHtml(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
out += ft8EscapeHtml(ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ft8TokenizeMessage(message) {
|
||||||
|
return String(message || "").toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
function ft8ExtractAllGrids(message) {
|
||||||
|
const out = [];
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
if (ft8IsAlphaNum(message[i])) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
||||||
|
seen.add(grid);
|
||||||
|
out.push(grid);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function ft8ExtractLocatorDetails(message) {
|
||||||
|
const tokens = ft8TokenizeMessage(message);
|
||||||
|
const grids = ft8ExtractAllGrids(String(message || ""));
|
||||||
|
if (tokens.length === 0 || grids.length === 0) return [];
|
||||||
|
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
||||||
|
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
||||||
|
const callsigns = [];
|
||||||
|
for (let i = 0; i < limit; i += 1) {
|
||||||
|
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
||||||
|
}
|
||||||
|
let source = null;
|
||||||
|
let target = null;
|
||||||
|
const head = tokens[0];
|
||||||
|
if (callsigns.length > 0) {
|
||||||
|
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
||||||
|
source = callsigns[0];
|
||||||
|
} else if (callsigns.length >= 2) {
|
||||||
|
target = callsigns[0];
|
||||||
|
source = callsigns[1];
|
||||||
|
} else {
|
||||||
|
source = callsigns[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return grids.map((grid) => ({
|
||||||
|
grid,
|
||||||
|
station: source || null,
|
||||||
|
source: source || null,
|
||||||
|
target: target || null
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
function ft8ExtractLikelyCallsign(message) {
|
||||||
|
const locatorDetails = ft8ExtractLocatorDetails(message);
|
||||||
|
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
||||||
|
return locatorDetails[0].station;
|
||||||
|
}
|
||||||
|
const tokens = ft8TokenizeMessage(message);
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (ft8IsLikelyCallsignToken(token)) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function ft8IsLikelyCallsignToken(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
if (token.length < 3 || token.length > 12) return false;
|
||||||
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
||||||
|
if (ft8IsMaidenheadGridToken(token)) return false;
|
||||||
|
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||||
|
}
|
||||||
|
function ft8IsFarewellToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
|
}
|
||||||
|
function ft8IsMaidenheadGridToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
||||||
|
}
|
||||||
|
function ft8EscapeHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function ft8IsAlphaNum(ch) {
|
||||||
|
return /[A-Za-z0-9]/.test(ch);
|
||||||
|
}
|
||||||
|
function activateFt8HistoryLocator(targetEl) {
|
||||||
|
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||||
|
if (!locatorEl) return false;
|
||||||
|
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
|
if (!grid) return false;
|
||||||
|
if (typeof window.navigateToMapLocator === "function") {
|
||||||
|
window.navigateToMapLocator(grid, "ft8");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function applyFt8FilterToRow(row) {
|
||||||
|
if (!ft8FilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.message || "";
|
||||||
|
row.style.display = message.includes(ft8FilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function applyFt8FilterToAll() {
|
||||||
|
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => applyFt8FilterToRow(row));
|
||||||
|
}
|
||||||
|
function updateFt8RowRf(row) {
|
||||||
|
const freqEl = row.querySelector(".ft8-freq");
|
||||||
|
if (!freqEl) return;
|
||||||
|
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
|
||||||
|
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
|
||||||
|
if (Number.isFinite(displayFreqHz)) {
|
||||||
|
freqEl.textContent = displayFreqHz.toFixed(0);
|
||||||
|
} else {
|
||||||
|
freqEl.textContent = "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.updateFt8RfDisplay = function() {
|
||||||
|
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => updateFt8RowRf(row));
|
||||||
|
updateFt8Bar();
|
||||||
|
};
|
||||||
|
window.resetFt8HistoryView = function() {
|
||||||
|
ft8MessagesEl.innerHTML = "";
|
||||||
|
ft8MessageHistory = [];
|
||||||
|
updateFt8Bar();
|
||||||
|
renderFt8History();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
|
||||||
|
};
|
||||||
|
if (ft8FilterInput) {
|
||||||
|
ft8FilterInput.addEventListener("input", () => {
|
||||||
|
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
|
||||||
|
renderFt8History();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ft8MessagesEl) {
|
||||||
|
ft8MessagesEl.addEventListener("click", (event) => {
|
||||||
|
if (!activateFt8HistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
ft8MessagesEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
if (!activateFt8HistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
||||||
|
ft8DecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
|
||||||
|
await postPath("/toggle_ft8_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT8 toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ft8_decode");
|
||||||
|
window.resetFt8HistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT8 history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.onServerFt8 = function(msg) {
|
||||||
|
ft8Status.textContent = "Receiving";
|
||||||
|
const next = normalizeServerFt8Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz,
|
||||||
|
locator_details: next.locatorDetails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addFt8Message(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"use strict";
|
||||||
|
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
|
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
|
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
|
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||||
|
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||||
|
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||||
|
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||||
|
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||||
|
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||||
|
let hfAprsFilterText = "";
|
||||||
|
let hfAprsPacketHistory = [];
|
||||||
|
let hfAprsOnlyPos = false;
|
||||||
|
let hfAprsHideCrc = false;
|
||||||
|
let hfAprsCollapseDup = false;
|
||||||
|
let hfAprsTypeFilter = "all";
|
||||||
|
function currentHfAprsHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneHfAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||||
|
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleHfAprsHistoryRender() {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
}
|
||||||
|
function hfAprsPacketCategory(pkt) {
|
||||||
|
const type = String(pkt.type || "").toLowerCase();
|
||||||
|
const info = String(pkt.info || "").toLowerCase();
|
||||||
|
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
function hfAprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position":
|
||||||
|
return "Position";
|
||||||
|
case "message":
|
||||||
|
return "Message";
|
||||||
|
case "weather":
|
||||||
|
return "Weather";
|
||||||
|
case "telemetry":
|
||||||
|
return "Telemetry";
|
||||||
|
default:
|
||||||
|
return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function hfAprsAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function hfAprsDistanceText(pkt) {
|
||||||
|
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function hfAprsPacketSignature(pkt) {
|
||||||
|
return [
|
||||||
|
pkt.srcCall || "",
|
||||||
|
pkt.destCall || "",
|
||||||
|
pkt.path || "",
|
||||||
|
pkt.info || "",
|
||||||
|
pkt.type || "",
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function hfAprsHexBytes(bytes) {
|
||||||
|
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||||
|
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function hfAprsFilterMatch(pkt) {
|
||||||
|
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||||
|
if (!hfAprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
hfAprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(hfAprsFilterText);
|
||||||
|
}
|
||||||
|
function hfAprsVisiblePackets() {
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
|
return packets.filter(hfAprsFilterMatch);
|
||||||
|
}
|
||||||
|
function collapseHfAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const key = hfAprsPacketSignature(pkt);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(pkt);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function updateHfAprsSummary() {
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
if (hfAprsTotalCountEl) {
|
||||||
|
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||||
|
}
|
||||||
|
if (hfAprsVisibleCountEl) {
|
||||||
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
|
}
|
||||||
|
if (hfAprsLatestSeenEl) {
|
||||||
|
const latest = hfAprsPacketHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function updateHfAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
||||||
|
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
||||||
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
|
}
|
||||||
|
function renderHfAprsInfo(pkt) {
|
||||||
|
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||||
|
if (bytes && bytes.length > 0) {
|
||||||
|
let out2 = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i];
|
||||||
|
if (b >= 32 && b <= 126) {
|
||||||
|
const ch = String.fromCharCode(b);
|
||||||
|
if (ch === "<") out2 += "<";
|
||||||
|
else if (ch === ">") out2 += ">";
|
||||||
|
else if (ch === "&") out2 += "&";
|
||||||
|
else if (ch === '"') out2 += """;
|
||||||
|
else out2 += ch;
|
||||||
|
} else {
|
||||||
|
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out2 += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out2;
|
||||||
|
}
|
||||||
|
const str = pkt.info || "";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code >= 32 && code <= 126) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
|
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const age = hfAprsAgeText(pkt._tsMs);
|
||||||
|
const category = hfAprsPacketCategory(pkt);
|
||||||
|
const categoryLabel = hfAprsCategoryLabel(category);
|
||||||
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||||
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
|
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
||||||
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
|
const posLink = pkt.lat != null && pkt.lon != null ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>` : "";
|
||||||
|
const distance = hfAprsDistanceText(pkt);
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
|
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + hfBadge + symbolHtml + `<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span><span>>${escapeMapHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeMapHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` + (posLink ? `<span>${posLink}</span>` : "") + `</div><div class="aprs-row-actions">` + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") + (pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div><details class="aprs-details"><summary>Details</summary><div class="aprs-details-grid"><span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span><span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span><span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span><span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = String(el.dataset.aprsMap || "");
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
window.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(raw);
|
||||||
|
showHint("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
showHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderHfAprsHistory() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
if (!hfAprsPacketsEl) {
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < visible.length; i++) {
|
||||||
|
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
|
||||||
|
}
|
||||||
|
hfAprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
}
|
||||||
|
window.resetHfAprsHistoryView = function() {
|
||||||
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
|
hfAprsPacketHistory = [];
|
||||||
|
renderHfAprsHistory();
|
||||||
|
};
|
||||||
|
window.pruneHfAprsHistoryView = function() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
renderHfAprsHistory();
|
||||||
|
};
|
||||||
|
function addHfAprsPacket(pkt) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
scheduleHfAprsHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
|
return {
|
||||||
|
rig_id: pkt.rig_id || null,
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
srcCall: pkt.src_call,
|
||||||
|
destCall: pkt.dest_call,
|
||||||
|
path: pkt.path,
|
||||||
|
info: pkt.info,
|
||||||
|
info_bytes: pkt.info_bytes,
|
||||||
|
type: pkt.packet_type,
|
||||||
|
crcOk: pkt.crc_ok,
|
||||||
|
ts_ms: pkt.ts_ms,
|
||||||
|
lat: pkt.lat,
|
||||||
|
lon: pkt.lon,
|
||||||
|
symbolTable: pkt.symbol_table,
|
||||||
|
symbolCode: pkt.symbol_code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerHfAprsBatch = function(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
scheduleHfAprsHistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreHfAprsHistory = function(packets) {
|
||||||
|
window.onServerHfAprsBatch(packets);
|
||||||
|
};
|
||||||
|
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||||
|
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||||
|
await postPath("/toggle_hf_aprs_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_hf_aprs_decode");
|
||||||
|
window.resetHfAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (hfAprsOnlyPosBtn) {
|
||||||
|
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
hfAprsOnlyPos = !hfAprsOnlyPos;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hfAprsHideCrcBtn) {
|
||||||
|
hfAprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
hfAprsHideCrc = !hfAprsHideCrc;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (hfAprsCollapseDupBtn) {
|
||||||
|
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
hfAprsCollapseDup = !hfAprsCollapseDup;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
hfAprsTypeFilter = type;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (hfAprsFilterInput) {
|
||||||
|
hfAprsFilterInput.addEventListener("input", () => {
|
||||||
|
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.onServerHfAprs = function(pkt) {
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
|
};
|
||||||
|
renderHfAprsHistory();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
|
||||||
+94
@@ -0,0 +1,94 @@
|
|||||||
|
"use strict";
|
||||||
|
(function() {
|
||||||
|
if (typeof L === "undefined") return;
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
function finiteAngle(value) {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
const normalized = (Number(value) % 360 + 360) % 360;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
function svgColor(value, fallback) {
|
||||||
|
const text = String(value || fallback || "");
|
||||||
|
return text.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function buildSymbolHtml(options, zoom) {
|
||||||
|
const heading = finiteAngle(options.heading);
|
||||||
|
const course = finiteAngle(options.course);
|
||||||
|
const angle = heading != null ? heading : course;
|
||||||
|
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||||||
|
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
||||||
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
|
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||||
|
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
||||||
|
const color = svgColor(options.color, "#ff7559");
|
||||||
|
const outline = svgColor(options.outline, "#6b2118");
|
||||||
|
const body = angle != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})"><path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" /></g>` : `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||||
|
const courseLine = course != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})"><line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" /></g>` : "";
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` + courseLine + body + `</svg>`;
|
||||||
|
}
|
||||||
|
L.TrxAisTrackSymbol = L.Marker.extend({
|
||||||
|
options: {
|
||||||
|
heading: null,
|
||||||
|
course: null,
|
||||||
|
speed: null,
|
||||||
|
color: "#ff7559",
|
||||||
|
outline: "#6b2118",
|
||||||
|
size: 22,
|
||||||
|
interactive: true,
|
||||||
|
keyboard: true,
|
||||||
|
riseOnHover: true
|
||||||
|
},
|
||||||
|
initialize: function(latlng, options) {
|
||||||
|
const merged = L.Util.extend({}, this.options, options || {});
|
||||||
|
merged.icon = L.divIcon({
|
||||||
|
className: "trx-ais-track-symbol-icon",
|
||||||
|
html: "",
|
||||||
|
iconSize: [merged.size, merged.size],
|
||||||
|
iconAnchor: [merged.size / 2, merged.size / 2]
|
||||||
|
});
|
||||||
|
L.Marker.prototype.initialize.call(this, latlng, merged);
|
||||||
|
},
|
||||||
|
onAdd: function(map) {
|
||||||
|
L.Marker.prototype.onAdd.call(this, map);
|
||||||
|
this._refreshIcon();
|
||||||
|
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||||||
|
map.on("zoomend", this._boundZoomRefresh);
|
||||||
|
},
|
||||||
|
onRemove: function(map) {
|
||||||
|
if (this._boundZoomRefresh) {
|
||||||
|
map.off("zoomend", this._boundZoomRefresh);
|
||||||
|
this._boundZoomRefresh = null;
|
||||||
|
}
|
||||||
|
L.Marker.prototype.onRemove.call(this, map);
|
||||||
|
},
|
||||||
|
setAisState: function(next) {
|
||||||
|
if (next && typeof next === "object") {
|
||||||
|
if ("heading" in next) this.options.heading = next.heading;
|
||||||
|
if ("course" in next) this.options.course = next.course;
|
||||||
|
if ("speed" in next) this.options.speed = next.speed;
|
||||||
|
if ("color" in next) this.options.color = next.color;
|
||||||
|
if ("outline" in next) this.options.outline = next.outline;
|
||||||
|
}
|
||||||
|
this._refreshIcon();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
_refreshIcon: function() {
|
||||||
|
if (!this._icon) return;
|
||||||
|
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
||||||
|
const html = buildSymbolHtml(this.options, zoom);
|
||||||
|
this._icon.innerHTML = html;
|
||||||
|
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
|
||||||
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
|
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||||
|
this._icon.style.width = `${size}px`;
|
||||||
|
this._icon.style.height = `${size}px`;
|
||||||
|
this._icon.style.marginLeft = `${-size / 2}px`;
|
||||||
|
this._icon.style.marginTop = `${-size / 2}px`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
L.trxAisTrackSymbol = function(latlng, options) {
|
||||||
|
return new L.TrxAisTrackSymbol(latlng, options);
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,256 @@
|
|||||||
|
"use strict";
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
const dom = {
|
||||||
|
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||||
|
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||||
|
body: document.getElementById("scheduler-sat-body"),
|
||||||
|
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||||
|
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||||
|
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||||
|
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||||
|
formTitle: document.getElementById("sch-sat-form-title"),
|
||||||
|
form: document.getElementById("sch-sat-form"),
|
||||||
|
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||||
|
preset: document.getElementById("scheduler-sat-preset"),
|
||||||
|
name: document.getElementById("scheduler-sat-name"),
|
||||||
|
norad: document.getElementById("scheduler-sat-norad"),
|
||||||
|
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||||
|
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||||
|
priority: document.getElementById("scheduler-sat-priority"),
|
||||||
|
centerHz: document.getElementById("scheduler-sat-center-hz")
|
||||||
|
};
|
||||||
|
let editIdx = null;
|
||||||
|
function getBridge() {
|
||||||
|
return window.schedulerBridge || {};
|
||||||
|
}
|
||||||
|
function getConfig() {
|
||||||
|
const b = getBridge();
|
||||||
|
return typeof b.getConfig === "function" ? b.getConfig() : null;
|
||||||
|
}
|
||||||
|
function getStatus() {
|
||||||
|
const b = getBridge();
|
||||||
|
return typeof b.getStatus === "function" ? b.getStatus() : null;
|
||||||
|
}
|
||||||
|
function getBookmarks() {
|
||||||
|
const b = getBridge();
|
||||||
|
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
|
||||||
|
}
|
||||||
|
function markDirty() {
|
||||||
|
var b = getBridge();
|
||||||
|
if (typeof b.markDirty === "function") b.markDirty();
|
||||||
|
}
|
||||||
|
function bmName(id) {
|
||||||
|
const bm = getBookmarks().find(function(b) {
|
||||||
|
return b.id === id;
|
||||||
|
});
|
||||||
|
return bm ? bm.name : String(id || "");
|
||||||
|
}
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||||
|
return hz + " Hz";
|
||||||
|
}
|
||||||
|
function getSatelliteEntries() {
|
||||||
|
var config = getConfig();
|
||||||
|
return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : [];
|
||||||
|
}
|
||||||
|
function ensureSatelliteConfig() {
|
||||||
|
var config = getConfig();
|
||||||
|
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites.entries) config.satellites.entries = [];
|
||||||
|
return config.satellites;
|
||||||
|
}
|
||||||
|
function collectSatelliteConfig() {
|
||||||
|
var enabled = dom.enabled ? dom.enabled.checked : false;
|
||||||
|
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||||
|
entries: getSatelliteEntries()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function renderSection() {
|
||||||
|
var config = getConfig();
|
||||||
|
var satCfg = config && config.satellites || {};
|
||||||
|
var enabled = !!satCfg.enabled;
|
||||||
|
if (dom.enabled) dom.enabled.checked = enabled;
|
||||||
|
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
|
||||||
|
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||||
|
renderEntries();
|
||||||
|
renderPassStatus();
|
||||||
|
}
|
||||||
|
function renderEntries() {
|
||||||
|
if (!dom.tbody) return;
|
||||||
|
var entries = getSatelliteEntries();
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
entries.forEach(function(entry, idx) {
|
||||||
|
var tr = document.createElement("tr");
|
||||||
|
var tdSat = document.createElement("td");
|
||||||
|
tdSat.textContent = entry.satellite || "";
|
||||||
|
tr.appendChild(tdSat);
|
||||||
|
var tdNorad = document.createElement("td");
|
||||||
|
tdNorad.textContent = entry.norad_id || "";
|
||||||
|
tr.appendChild(tdNorad);
|
||||||
|
var tdBm = document.createElement("td");
|
||||||
|
tdBm.textContent = bmName(entry.bookmark_id);
|
||||||
|
tr.appendChild(tdBm);
|
||||||
|
var tdEl = document.createElement("td");
|
||||||
|
tdEl.textContent = entry.min_elevation_deg != null ? entry.min_elevation_deg + "°" : "5°";
|
||||||
|
tr.appendChild(tdEl);
|
||||||
|
var tdPrio = document.createElement("td");
|
||||||
|
tdPrio.textContent = entry.priority || 0;
|
||||||
|
tr.appendChild(tdPrio);
|
||||||
|
var tdActions = document.createElement("td");
|
||||||
|
var editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "sch-write";
|
||||||
|
editBtn.type = "button";
|
||||||
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.addEventListener("click", function() {
|
||||||
|
openForm(entry, idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(editBtn);
|
||||||
|
var removeBtn = document.createElement("button");
|
||||||
|
removeBtn.className = "sch-write";
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.textContent = "Remove";
|
||||||
|
removeBtn.addEventListener("click", function() {
|
||||||
|
removeEntry(idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(removeBtn);
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
frag.appendChild(tr);
|
||||||
|
});
|
||||||
|
dom.tbody.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
function renderPassStatus() {
|
||||||
|
if (!dom.passStatus) return;
|
||||||
|
var entries = getSatelliteEntries();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
dom.passStatus.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var status = getStatus();
|
||||||
|
if (status && status.active_satellite) {
|
||||||
|
dom.passStatus.innerHTML = '<span class="sch-sat-active-badge">PASS ACTIVE: ' + escHtml(status.active_satellite) + "</span>";
|
||||||
|
} else {
|
||||||
|
dom.passStatus.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderBookmarkSelect(selectedId) {
|
||||||
|
if (!dom.bookmark) return;
|
||||||
|
dom.bookmark.innerHTML = '<option value="">— none —</option>';
|
||||||
|
getBookmarks().forEach(function(bm) {
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = bm.id;
|
||||||
|
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||||
|
if (bm.id === selectedId) opt.selected = true;
|
||||||
|
dom.bookmark.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeEntry(idx) {
|
||||||
|
var sat = ensureSatelliteConfig();
|
||||||
|
sat.entries.splice(idx, 1);
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function openForm(entry, idx) {
|
||||||
|
editIdx = idx != null ? idx : null;
|
||||||
|
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||||
|
if (dom.preset) dom.preset.value = "";
|
||||||
|
if (dom.name) dom.name.value = entry ? entry.satellite || "" : "";
|
||||||
|
if (dom.norad) dom.norad.value = entry ? entry.norad_id || "" : "";
|
||||||
|
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
|
||||||
|
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
|
||||||
|
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
|
||||||
|
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||||
|
if (dom.formWrap) {
|
||||||
|
dom.formWrap.style.display = "flex";
|
||||||
|
if (dom.name) dom.name.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function closeForm() {
|
||||||
|
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||||
|
editIdx = null;
|
||||||
|
}
|
||||||
|
function onFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var satellite = dom.name ? dom.name.value.trim() : "";
|
||||||
|
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||||
|
var bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||||
|
if (!satellite) {
|
||||||
|
window.trxUi?.notify("Enter a satellite name.", { kind: "error" });
|
||||||
|
document.getElementById("scheduler-sat-name")?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isNaN(noradId) || noradId <= 0) {
|
||||||
|
window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" });
|
||||||
|
document.getElementById("scheduler-sat-norad")?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bmId) {
|
||||||
|
window.trxUi?.notify("Select a bookmark.", { kind: "error" });
|
||||||
|
document.getElementById("scheduler-sat-bookmark")?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||||
|
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||||
|
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||||
|
var sat = ensureSatelliteConfig();
|
||||||
|
var entryData = {
|
||||||
|
satellite,
|
||||||
|
norad_id: noradId,
|
||||||
|
bookmark_id: bmId,
|
||||||
|
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||||
|
priority: isNaN(prio) ? 0 : prio,
|
||||||
|
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||||
|
bookmark_ids: []
|
||||||
|
};
|
||||||
|
if (editIdx !== null) {
|
||||||
|
var existing = sat.entries[editIdx];
|
||||||
|
entryData.id = existing ? existing.id : "sat_" + Date.now().toString(36);
|
||||||
|
sat.entries[editIdx] = entryData;
|
||||||
|
} else {
|
||||||
|
entryData.id = "sat_" + Date.now().toString(36);
|
||||||
|
sat.entries.push(entryData);
|
||||||
|
}
|
||||||
|
closeForm();
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function onPresetChange() {
|
||||||
|
if (!dom.preset || !dom.preset.value) return;
|
||||||
|
var parts = dom.preset.value.split("|");
|
||||||
|
if (dom.name) dom.name.value = parts[0] || "";
|
||||||
|
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||||
|
}
|
||||||
|
function wireEvents() {
|
||||||
|
if (dom.enabled) {
|
||||||
|
dom.enabled.addEventListener("change", function() {
|
||||||
|
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.pretune) {
|
||||||
|
dom.pretune.addEventListener("input", function() {
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.addBtn) dom.addBtn.addEventListener("click", function() {
|
||||||
|
openForm(null, null);
|
||||||
|
});
|
||||||
|
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
|
||||||
|
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
|
||||||
|
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
|
||||||
|
}
|
||||||
|
window.satScheduler = {
|
||||||
|
wireEvents,
|
||||||
|
renderSection,
|
||||||
|
renderPassStatus,
|
||||||
|
collectSatelliteConfig
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,447 @@
|
|||||||
|
"use strict";
|
||||||
|
const satDom = {
|
||||||
|
status: document.getElementById("sat-status"),
|
||||||
|
liveView: document.getElementById("sat-live-view"),
|
||||||
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
|
historyList: document.getElementById("sat-history-list"),
|
||||||
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
|
filterInput: document.getElementById("sat-filter"),
|
||||||
|
sortSelect: document.getElementById("sat-sort"),
|
||||||
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
|
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")
|
||||||
|
};
|
||||||
|
let satImageHistory = [];
|
||||||
|
const SAT_MAX_IMAGES = 100;
|
||||||
|
const SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
let satPredShowAll = false;
|
||||||
|
let satFilterText = "";
|
||||||
|
let satActiveView = "live";
|
||||||
|
let satPredData = [];
|
||||||
|
let satPredFilterText = "";
|
||||||
|
let satPredMinEl = 0;
|
||||||
|
let satPredCategory = "all";
|
||||||
|
let satPredSatCount = 0;
|
||||||
|
let satPredCountdownTimer = null;
|
||||||
|
function scheduleSatUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function switchSatView(view) {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
|
satActiveView = view;
|
||||||
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
|
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;
|
||||||
|
loadSatPredictions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
window.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
|
||||||
|
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
||||||
|
let _lastSatLrptOn = null;
|
||||||
|
window.updateSatLiveState = function(update) {
|
||||||
|
if (!satDom.lrptState) return;
|
||||||
|
const lrptOn = !!update.lrpt_decode_enabled;
|
||||||
|
if (lrptOn !== _lastSatLrptOn) {
|
||||||
|
_lastSatLrptOn = lrptOn;
|
||||||
|
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||||
|
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||||
|
if (satDom.status) {
|
||||||
|
if (lrptOn) {
|
||||||
|
satDom.status.textContent = "Decoder active — waiting for signal";
|
||||||
|
} else {
|
||||||
|
satDom.status.textContent = "Decoder idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function renderSatLatestCard() {
|
||||||
|
if (!satDom.liveLatest) return;
|
||||||
|
if (satImageHistory.length === 0) {
|
||||||
|
satDom.liveLatest.innerHTML = '<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const img = satImageHistory[0];
|
||||||
|
const decoder = img._decoder || "unknown";
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const satellite = img.satellite || "";
|
||||||
|
const channels = img.channels || img.channel_a || "";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU rows";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
let meta = [typeName];
|
||||||
|
if (satellite) meta.push(satellite);
|
||||||
|
if (channels) meta.push(channels);
|
||||||
|
meta.push(`${lines} ${unit}`);
|
||||||
|
meta.push(`${date} ${ts}`);
|
||||||
|
let html = `<div class="sat-latest-card">`;
|
||||||
|
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
||||||
|
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
||||||
|
if (img.path) {
|
||||||
|
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||||
|
}
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
satDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
function getSatFilteredHistory() {
|
||||||
|
let items = satImageHistory;
|
||||||
|
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||||
|
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||||
|
if (satFilterText) {
|
||||||
|
items = items.filter((i) => {
|
||||||
|
const haystack = [
|
||||||
|
"meteor lrpt",
|
||||||
|
i.satellite || "",
|
||||||
|
i.channels || "",
|
||||||
|
i.channel_a || "",
|
||||||
|
i.channel_b || ""
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.includes(satFilterText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function renderSatHistoryRow(img) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
const decoder = img._decoder || "unknown";
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const typeClass = "sat-type-lrpt";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
const satellite = img.satellite || "--";
|
||||||
|
const channels = img.channels || "--";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU";
|
||||||
|
let link = img.path ? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>` : "--";
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||||
|
}
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span>${date} ${ts}</span>`,
|
||||||
|
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
||||||
|
`<span>${satellite}</span>`,
|
||||||
|
`<span>${channels}</span>`,
|
||||||
|
`<span>${lines} ${unit}</span>`,
|
||||||
|
`<span>${link}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderSatHistoryTable() {
|
||||||
|
if (!satDom.historyList) return;
|
||||||
|
const items = getSatFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
fragment.appendChild(renderSatHistoryRow(items[i]));
|
||||||
|
}
|
||||||
|
satDom.historyList.replaceChildren(fragment);
|
||||||
|
if (satDom.historyCount) {
|
||||||
|
const total = satImageHistory.length;
|
||||||
|
const shown = items.length;
|
||||||
|
satDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${total} image${total === 1 ? "" : "s"}` : `${shown} of ${total} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addSatImage(img, decoder) {
|
||||||
|
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||||
|
img._tsMs = tsMs;
|
||||||
|
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
img._decoder = decoder;
|
||||||
|
satImageHistory.unshift(img);
|
||||||
|
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
||||||
|
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
scheduleSatUi("sat-latest", () => renderSatLatestCard());
|
||||||
|
if (satActiveView === "history") {
|
||||||
|
scheduleSatUi("sat-history", () => renderSatHistoryTable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.onServerLrptProgress = function(msg) {
|
||||||
|
if (satDom.status && msg.mcu_count > 0) {
|
||||||
|
satDom.status.textContent = "Receiving — " + msg.mcu_count + " MCU rows decoded";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.onServerLrptImage = function(msg) {
|
||||||
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
|
addSatImage(msg, "lrpt");
|
||||||
|
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
||||||
|
window.addSatMapOverlay(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.resetSatHistoryView = function() {
|
||||||
|
satImageHistory = [];
|
||||||
|
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
|
||||||
|
};
|
||||||
|
window.pruneSatHistoryView = function() {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
renderSatLatestCard();
|
||||||
|
};
|
||||||
|
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
|
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||||
|
await postPath("/toggle_lrpt_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("LRPT toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
satDom.filterInput?.addEventListener("input", () => {
|
||||||
|
satFilterText = satDom.filterInput.value.trim().toUpperCase();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
|
||||||
|
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
||||||
|
document.getElementById("settings-clear-sat-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_lrpt_decode");
|
||||||
|
window.resetSatHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Weather satellite history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
function azToCardinal(deg) {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8];
|
||||||
|
}
|
||||||
|
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 ? 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 = parseInt(el.dataset.los, 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());
|
||||||
|
}
|
||||||
|
satDom.predFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
satDom.predMinEl?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
satDom.predCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satDom.predCategory.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 (e) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.satShowOnMap = function(south, west, north, east) {
|
||||||
|
if (typeof window.enableMapSourceFilter === "function") {
|
||||||
|
window.enableMapSourceFilter("sat");
|
||||||
|
}
|
||||||
|
const lat = (south + north) / 2;
|
||||||
|
const lon = (west + east) / 2;
|
||||||
|
if (window.navigateToAprsMap) {
|
||||||
|
window.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,236 @@
|
|||||||
|
"use strict";
|
||||||
|
(function() {
|
||||||
|
"use strict";
|
||||||
|
const T = window.trx;
|
||||||
|
function isVisibleForSnapshot(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||||
|
const opacity = Number(style.opacity);
|
||||||
|
if (Number.isFinite(opacity) && opacity <= 0) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
}
|
||||||
|
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||||
|
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + w - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
||||||
|
ctx.lineTo(x + w, y + h - radius);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
||||||
|
ctx.lineTo(x + radius, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||||
|
if (!isVisibleForSnapshot(el)) return null;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const x = rect.left - rootRect.left;
|
||||||
|
const y = rect.top - rootRect.top;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
const radius = parseFloat(style.borderTopLeftRadius) || 0;
|
||||||
|
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||||
|
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||||
|
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||||
|
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||||
|
if (bgAlpha > 0.01) {
|
||||||
|
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||||
|
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||||
|
if (borderWidth > 0 && borderAlpha > 0.01) {
|
||||||
|
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
||||||
|
ctx.lineWidth = borderWidth;
|
||||||
|
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
return { x, y, w, h, style };
|
||||||
|
}
|
||||||
|
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
|
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||||
|
if (!words.length) return;
|
||||||
|
let line = "";
|
||||||
|
let lineIdx = 0;
|
||||||
|
for (let i = 0; i < words.length; i += 1) {
|
||||||
|
const candidate = line ? `${line} ${words[i]}` : words[i];
|
||||||
|
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||||
|
line = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
|
lineIdx += 1;
|
||||||
|
if (lineIdx >= maxLines) return;
|
||||||
|
line = words[i];
|
||||||
|
}
|
||||||
|
if (line && lineIdx < maxLines) {
|
||||||
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||||
|
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||||
|
if (!chrome) return;
|
||||||
|
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
|
||||||
|
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
|
||||||
|
if (!clean) return;
|
||||||
|
const style = chrome.style;
|
||||||
|
const fontSize = parseFloat(style.fontSize) || 12;
|
||||||
|
const lineHeight = parseFloat(style.lineHeight) || fontSize * 1.25;
|
||||||
|
const padX = 6;
|
||||||
|
const padY = 4;
|
||||||
|
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||||
|
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
|
||||||
|
ctx.fillStyle = style.color || "#ffffff";
|
||||||
|
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
const lines = clean.split(/\n+/);
|
||||||
|
let lineCursor = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (lineCursor >= maxLines) break;
|
||||||
|
drawWrappedText(
|
||||||
|
ctx,
|
||||||
|
line,
|
||||||
|
chrome.x + padX,
|
||||||
|
chrome.y + padY + lineCursor * lineHeight,
|
||||||
|
maxWidth,
|
||||||
|
lineHeight,
|
||||||
|
maxLines - lineCursor
|
||||||
|
);
|
||||||
|
lineCursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||||
|
if (!isVisibleForSnapshot(axisEl)) return;
|
||||||
|
for (const node of axisEl.children) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue;
|
||||||
|
if (!(node.matches("span") || node.matches("button"))) continue;
|
||||||
|
if (!isVisibleForSnapshot(node)) continue;
|
||||||
|
const chrome = drawElementChrome(ctx, node, rootRect);
|
||||||
|
const text = (node.textContent || "").trim();
|
||||||
|
if (!chrome || !text) continue;
|
||||||
|
const style = chrome.style;
|
||||||
|
ctx.fillStyle = style.color || "#ffffff";
|
||||||
|
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function buildSpectrumSnapshotCanvas() {
|
||||||
|
const rootEl = document.querySelector(".signal-visual-block");
|
||||||
|
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||||
|
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
|
||||||
|
const gl = renderer?.gl;
|
||||||
|
if (!gl) continue;
|
||||||
|
try {
|
||||||
|
if (typeof gl.flush === "function") gl.flush();
|
||||||
|
if (typeof gl.finish === "function") gl.finish();
|
||||||
|
} catch (_) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rootRect = rootEl.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const out = document.createElement("canvas");
|
||||||
|
out.width = Math.max(1, Math.round(rootRect.width * dpr));
|
||||||
|
out.height = Math.max(1, Math.round(rootRect.height * dpr));
|
||||||
|
const ctx = out.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||||
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||||
|
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||||
|
for (const canvas of canvases) {
|
||||||
|
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
ctx.drawImage(
|
||||||
|
canvas,
|
||||||
|
rect.left - rootRect.left,
|
||||||
|
rect.top - rootRect.top,
|
||||||
|
rect.width,
|
||||||
|
rect.height
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const decoderOverlayIds = [
|
||||||
|
"ais-bar-overlay",
|
||||||
|
"vdes-bar-overlay",
|
||||||
|
"ft8-bar-overlay",
|
||||||
|
"aprs-bar-overlay",
|
||||||
|
"rds-ps-overlay"
|
||||||
|
];
|
||||||
|
for (const id of decoderOverlayIds) {
|
||||||
|
const overlayEl = document.getElementById(id);
|
||||||
|
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||||
|
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||||
|
}
|
||||||
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
|
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||||
|
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||||
|
drawAxisLabels(ctx, spectrumDbAxis, rootRect);
|
||||||
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||||
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||||
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function clickCanvasDownload(href, fileName) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = href;
|
||||||
|
a.download = fileName;
|
||||||
|
a.rel = "noopener";
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
requestAnimationFrame(() => a.remove());
|
||||||
|
}
|
||||||
|
function saveCanvasAsPng(canvas, fileName) {
|
||||||
|
if (!canvas) return Promise.resolve(false);
|
||||||
|
if (typeof canvas.toBlob === "function") {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
clickCanvasDownload(url, fileName);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1e3);
|
||||||
|
resolve(true);
|
||||||
|
}, "image/png");
|
||||||
|
} catch (_) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
} catch (_) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function captureSpectrumScreenshot() {
|
||||||
|
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||||
|
if (!snapshotCanvas) {
|
||||||
|
T.showHint("Spectrum view not ready", 1300);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
|
||||||
|
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||||
|
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
window.trx.modules.screenshot = {
|
||||||
|
captureSpectrumScreenshot,
|
||||||
|
buildSpectrumSnapshotCanvas,
|
||||||
|
saveCanvasAsPng
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
"use strict";
|
||||||
|
(function initUiCore() {
|
||||||
|
const api = window.trxUi = window.trxUi || {};
|
||||||
|
function ensureLiveRegions() {
|
||||||
|
if (!document.getElementById("toast-region")) {
|
||||||
|
const region = document.createElement("div");
|
||||||
|
region.id = "toast-region";
|
||||||
|
region.className = "toast-region";
|
||||||
|
region.setAttribute("aria-live", "polite");
|
||||||
|
region.setAttribute("aria-atomic", "false");
|
||||||
|
document.body.appendChild(region);
|
||||||
|
}
|
||||||
|
if (!document.getElementById("ui-confirm-dialog")) {
|
||||||
|
const dialog = document.createElement("dialog");
|
||||||
|
dialog.id = "ui-confirm-dialog";
|
||||||
|
dialog.className = "ui-dialog";
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<form method="dialog" class="ui-dialog-card">
|
||||||
|
<h2 id="ui-confirm-title">Confirm action</h2>
|
||||||
|
<p id="ui-confirm-message"></p>
|
||||||
|
<div class="ui-dialog-actions">
|
||||||
|
<button value="cancel" type="submit">Cancel</button>
|
||||||
|
<button value="confirm" type="submit" class="danger">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
api.notify = function notify(message, options = {}) {
|
||||||
|
ensureLiveRegions();
|
||||||
|
const { kind = "info", duration = kind === "error" ? 7e3 : 3200, action = null } = options;
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = `toast toast-${kind}`;
|
||||||
|
toast.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = message;
|
||||||
|
toast.appendChild(text);
|
||||||
|
if (action && typeof action.run === "function") {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = action.label || "Retry";
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
action.run();
|
||||||
|
toast.remove();
|
||||||
|
});
|
||||||
|
toast.appendChild(button);
|
||||||
|
}
|
||||||
|
document.getElementById("toast-region").appendChild(toast);
|
||||||
|
requestAnimationFrame(() => toast.classList.add("toast-visible"));
|
||||||
|
if (duration > 0) setTimeout(() => toast.remove(), duration);
|
||||||
|
return toast;
|
||||||
|
};
|
||||||
|
api.confirm = function confirmAction(options = {}) {
|
||||||
|
ensureLiveRegions();
|
||||||
|
const dialog = document.getElementById("ui-confirm-dialog");
|
||||||
|
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
||||||
|
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
|
||||||
|
const confirmButton = dialog.querySelector('[value="confirm"]');
|
||||||
|
confirmButton.textContent = options.confirmLabel || "Confirm";
|
||||||
|
confirmButton.classList.toggle("danger", options.danger !== false);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = () => resolve(dialog.returnValue === "confirm");
|
||||||
|
dialog.addEventListener("close", finish, { once: true });
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
api.setButtonState = function setButtonState(button, options = {}) {
|
||||||
|
if (!button) return;
|
||||||
|
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
|
||||||
|
button.classList.toggle("is-active", active);
|
||||||
|
button.classList.toggle("is-busy", busy);
|
||||||
|
button.setAttribute("aria-pressed", String(active));
|
||||||
|
button.setAttribute("aria-busy", String(busy));
|
||||||
|
button.disabled = disabled || busy;
|
||||||
|
const label = active ? activeLabel : inactiveLabel;
|
||||||
|
if (label) button.textContent = label;
|
||||||
|
};
|
||||||
|
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
|
||||||
|
if (!bar) return;
|
||||||
|
if (bar._accessibleTabsPrepared) return;
|
||||||
|
bar._accessibleTabsPrepared = true;
|
||||||
|
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
||||||
|
const buttons = Array.from(bar.querySelectorAll(selector));
|
||||||
|
bar.setAttribute("role", "tablist");
|
||||||
|
buttons.forEach((button, index) => {
|
||||||
|
button.setAttribute("role", "tab");
|
||||||
|
button.setAttribute("aria-selected", String(button.classList.contains("active")));
|
||||||
|
button.tabIndex = button.classList.contains("active") || !buttons.some((b) => b.classList.contains("active")) && index === 0 ? 0 : -1;
|
||||||
|
const key = button.dataset.tab || button.dataset.subtab;
|
||||||
|
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
|
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
|
if (panel) {
|
||||||
|
if (!button.id) button.id = `${kind}-tab-${key}`;
|
||||||
|
panel.setAttribute("role", "tabpanel");
|
||||||
|
panel.setAttribute("aria-labelledby", button.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bar.addEventListener("keydown", (event) => {
|
||||||
|
if (!buttons.includes(event.target)) return;
|
||||||
|
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1 : event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
||||||
|
if (!direction) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
||||||
|
next.focus();
|
||||||
|
next.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
|
||||||
|
if (!bar) return;
|
||||||
|
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||||
|
const active = tab === selected;
|
||||||
|
tab.setAttribute("aria-selected", String(active));
|
||||||
|
tab.tabIndex = active ? 0 : -1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const layouts = {
|
||||||
|
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
||||||
|
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||||
|
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||||
|
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" }
|
||||||
|
};
|
||||||
|
const layoutCapabilities = { broadcast: false, digital: false };
|
||||||
|
let activeRigId = null;
|
||||||
|
function layoutStorageKey() {
|
||||||
|
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
|
||||||
|
}
|
||||||
|
function savedLayoutName() {
|
||||||
|
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
||||||
|
}
|
||||||
|
function layoutAvailable(layout) {
|
||||||
|
return !layout.capability || layoutCapabilities[layout.capability] === true;
|
||||||
|
}
|
||||||
|
function unavailableLayoutMessage() {
|
||||||
|
const unavailable = Object.values(layouts).filter((layout) => !layoutAvailable(layout) && layout.unavailable);
|
||||||
|
return unavailable.length ? `Unavailable: ${unavailable.map((layout) => layout.unavailable).join("; ")}.` : "";
|
||||||
|
}
|
||||||
|
function refreshLayoutOptions() {
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
if (!select) return;
|
||||||
|
const previous = select.value || document.body.dataset.operatorLayout || "compact";
|
||||||
|
select.replaceChildren();
|
||||||
|
Object.entries(layouts).forEach(([value, layout]) => {
|
||||||
|
if (!layoutAvailable(layout)) return;
|
||||||
|
select.add(new Option(layout.label, value));
|
||||||
|
});
|
||||||
|
const available = Array.from(select.options).some((option) => option.value === previous);
|
||||||
|
select.value = available ? previous : "compact";
|
||||||
|
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
|
||||||
|
select.title = unavailableLayoutMessage();
|
||||||
|
}
|
||||||
|
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
|
||||||
|
Object.keys(layoutCapabilities).forEach((name) => {
|
||||||
|
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
|
||||||
|
});
|
||||||
|
refreshLayoutOptions();
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
const saved = savedLayoutName();
|
||||||
|
if (select && Array.from(select.options).some((option) => option.value === saved)) {
|
||||||
|
select.value = saved;
|
||||||
|
api.applyLayout(saved, { persist: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
api.setActiveRig = function setActiveRig(rigId) {
|
||||||
|
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
|
||||||
|
const saved = savedLayoutName();
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
if (select) select.value = Array.from(select.options).some((option) => option.value === saved) ? saved : "compact";
|
||||||
|
api.applyLayout(select?.value || saved, { persist: false });
|
||||||
|
};
|
||||||
|
api.applyLayout = function applyLayout(name, options = {}) {
|
||||||
|
const requestedLayout = layouts[name];
|
||||||
|
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
|
||||||
|
const layout = layouts[permittedName];
|
||||||
|
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
|
||||||
|
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
|
||||||
|
const details = document.getElementById("advanced-radio-controls");
|
||||||
|
if (details) details.open = layout.advanced;
|
||||||
|
const audioDetails = document.getElementById("audio-controls");
|
||||||
|
if (audioDetails) audioDetails.open = layout.audio;
|
||||||
|
const schedulerDetails = document.getElementById("scheduler-controls");
|
||||||
|
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
|
||||||
|
if (options.navigate && typeof window.navigateToTab === "function") {
|
||||||
|
window.navigateToTab(layout.preferredTab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
function installLayoutControls() {
|
||||||
|
const actions = document.querySelector(".top-bar-actions");
|
||||||
|
if (actions && !document.getElementById("operator-layout-select")) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "operator-layout-picker";
|
||||||
|
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
||||||
|
const select = label.querySelector("select");
|
||||||
|
const savedLayout = savedLayoutName();
|
||||||
|
actions.insertBefore(label, actions.firstChild);
|
||||||
|
select.value = savedLayout;
|
||||||
|
refreshLayoutOptions();
|
||||||
|
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
|
||||||
|
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
|
||||||
|
api.applyLayout(select.value);
|
||||||
|
}
|
||||||
|
const tray = document.querySelector(".controls-tray");
|
||||||
|
if (tray && !document.getElementById("advanced-radio-controls")) {
|
||||||
|
const details = document.createElement("details");
|
||||||
|
details.id = "advanced-radio-controls";
|
||||||
|
details.className = "advanced-radio-controls";
|
||||||
|
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
||||||
|
const body = details.querySelector(".advanced-radio-body");
|
||||||
|
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) body.appendChild(element);
|
||||||
|
});
|
||||||
|
tray.appendChild(details);
|
||||||
|
api.applyLayout(savedLayoutName(), { persist: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function installMobileMore() {
|
||||||
|
const nav = document.querySelector(".tab-bar-nav");
|
||||||
|
if (!nav || document.getElementById("mobile-more-btn")) return;
|
||||||
|
const more = document.createElement("button");
|
||||||
|
more.id = "mobile-more-btn";
|
||||||
|
more.className = "tab mobile-more-btn";
|
||||||
|
more.type = "button";
|
||||||
|
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
|
||||||
|
more.setAttribute("aria-haspopup", "menu");
|
||||||
|
more.setAttribute("aria-expanded", "false");
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.id = "mobile-more-menu";
|
||||||
|
menu.className = "mobile-more-menu";
|
||||||
|
menu.setAttribute("role", "menu");
|
||||||
|
more.setAttribute("aria-controls", menu.id);
|
||||||
|
const closeMore = (restoreFocus = false) => {
|
||||||
|
if (!menu.classList.contains("is-open")) return;
|
||||||
|
menu.classList.remove("is-open");
|
||||||
|
more.setAttribute("aria-expanded", "false");
|
||||||
|
if (restoreFocus) more.focus();
|
||||||
|
};
|
||||||
|
api.closeMobileOverlays = closeMore;
|
||||||
|
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
if (!source) return;
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.setAttribute("role", "menuitem");
|
||||||
|
item.dataset.navigateTab = tabName;
|
||||||
|
item.textContent = source.textContent.trim();
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
|
||||||
|
closeMore();
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
});
|
||||||
|
more.addEventListener("click", () => {
|
||||||
|
const open = menu.classList.toggle("is-open");
|
||||||
|
more.setAttribute("aria-expanded", String(open));
|
||||||
|
if (open) menu.querySelector('[role="menuitem"]')?.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!menu.contains(event.target) && !more.contains(event.target)) closeMore();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeMore(true);
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => closeMore());
|
||||||
|
window.addEventListener("popstate", () => closeMore());
|
||||||
|
nav.append(more, menu);
|
||||||
|
}
|
||||||
|
function installDecoderPicker() {
|
||||||
|
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||||
|
if (!bar || document.getElementById("decoder-tab-select")) return;
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = "decoder-tab-select";
|
||||||
|
select.className = "decoder-tab-select";
|
||||||
|
select.setAttribute("aria-label", "Decoder view");
|
||||||
|
const groups = [
|
||||||
|
["Overview", ["overview"]],
|
||||||
|
["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]],
|
||||||
|
["Broadcast & images", ["rds", "sat", "wefax"]]
|
||||||
|
];
|
||||||
|
groups.forEach(([label, ids]) => {
|
||||||
|
const group = document.createElement("optgroup");
|
||||||
|
group.label = label;
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const button = bar.querySelector(`[data-subtab="${id}"]`);
|
||||||
|
if (button) group.appendChild(new Option(button.textContent.trim(), id));
|
||||||
|
});
|
||||||
|
select.appendChild(group);
|
||||||
|
});
|
||||||
|
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
|
||||||
|
bar.insertAdjacentElement("afterend", select);
|
||||||
|
}
|
||||||
|
function installDecoderBadges() {
|
||||||
|
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
||||||
|
const id = button.dataset.subtab;
|
||||||
|
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "decoder-state-dot";
|
||||||
|
dot.setAttribute("aria-hidden", "true");
|
||||||
|
button.appendChild(dot);
|
||||||
|
const status = document.getElementById(`${id}-status`);
|
||||||
|
if (!status) return;
|
||||||
|
const sync = () => {
|
||||||
|
const value = status.textContent.toLowerCase();
|
||||||
|
const state = /receiv|decod|connected|listening/.test(value) ? "active" : /error|fail|disconnected/.test(value) ? "error" : "idle";
|
||||||
|
dot.dataset.state = state;
|
||||||
|
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
|
||||||
|
};
|
||||||
|
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
api.init = function init() {
|
||||||
|
ensureLiveRegions();
|
||||||
|
installLayoutControls();
|
||||||
|
installMobileMore();
|
||||||
|
installDecoderPicker();
|
||||||
|
installDecoderBadges();
|
||||||
|
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
||||||
|
document.querySelectorAll(".sub-tab-bar").forEach((bar) => api.prepareTabList(bar, "secondary"));
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const message = event.reason?.message || "An operation failed unexpectedly";
|
||||||
|
api.notify(message, { kind: "error" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
|
||||||
|
else api.init();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
"use strict";
|
||||||
|
let vchanSessionId = null;
|
||||||
|
let vchanRigId = null;
|
||||||
|
let vchanChannels = [];
|
||||||
|
let vchanActiveId = null;
|
||||||
|
let schedulerReleaseState = null;
|
||||||
|
let schedulerReleasePollTimer = null;
|
||||||
|
function vchanFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return hz + " Hz";
|
||||||
|
}
|
||||||
|
function schedulerReleaseSummaryText(state) {
|
||||||
|
if (!state) return "Scheduler is controlling the rig.";
|
||||||
|
const connected = Number(state.connected_sessions) || 0;
|
||||||
|
const released = Number(state.released_sessions) || 0;
|
||||||
|
if (connected === 0) return "Scheduler can control the rig.";
|
||||||
|
if (state.all_released) {
|
||||||
|
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
|
||||||
|
}
|
||||||
|
if (!state.current_session_released) {
|
||||||
|
const othersReleased = Math.max(released, 0);
|
||||||
|
return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler.";
|
||||||
|
}
|
||||||
|
const blocking = Math.max(connected - released, 0);
|
||||||
|
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
|
||||||
|
}
|
||||||
|
function vchanRenderSchedulerRelease() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
const status = document.getElementById("scheduler-release-status");
|
||||||
|
if (!btn || !status) return;
|
||||||
|
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||||
|
btn.disabled = !vchanSessionId || currentReleased;
|
||||||
|
btn.classList.toggle("active", !currentReleased);
|
||||||
|
btn.textContent = "Release to Scheduler";
|
||||||
|
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||||
|
}
|
||||||
|
async function vchanPollSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) {
|
||||||
|
schedulerReleaseState = null;
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release status failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanStartSchedulerReleasePolling() {
|
||||||
|
if (schedulerReleasePollTimer) {
|
||||||
|
clearInterval(schedulerReleasePollTimer);
|
||||||
|
}
|
||||||
|
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 1e4);
|
||||||
|
}
|
||||||
|
async function vchanToggleSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release toggle failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanTakeSchedulerControl() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: false })
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler control takeover failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||||
|
function vchanHandleSession(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanSessionId = d.session_id || null;
|
||||||
|
vchanPollSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad session event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanHandleChannels(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanRigId = d.remote || null;
|
||||||
|
vchanChannels = d.channels || [];
|
||||||
|
const ids = new Set(vchanChannels.map((c) => c.id));
|
||||||
|
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
|
||||||
|
vchanAutoJoinPrimary(vchanChannels[0].id);
|
||||||
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||||
|
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
||||||
|
vchanReconnectAudio();
|
||||||
|
}
|
||||||
|
vchanRender();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad channels event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanRender() {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.innerHTML = "";
|
||||||
|
vchanChannels.forEach((ch) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||||
|
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "vchan-label";
|
||||||
|
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||||
|
btn.appendChild(label);
|
||||||
|
if (!ch.permanent) {
|
||||||
|
const del = document.createElement("span");
|
||||||
|
del.className = "vchan-del";
|
||||||
|
del.textContent = "×";
|
||||||
|
del.title = "Delete channel";
|
||||||
|
del.addEventListener("click", (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
vchanDelete(ch.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(del);
|
||||||
|
}
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
||||||
|
});
|
||||||
|
picker.appendChild(btn);
|
||||||
|
});
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.type = "button";
|
||||||
|
addBtn.className = "vchan-add";
|
||||||
|
addBtn.textContent = "+";
|
||||||
|
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||||
|
addBtn.addEventListener("click", vchanAllocate);
|
||||||
|
picker.appendChild(addBtn);
|
||||||
|
vchanSyncAccentUI();
|
||||||
|
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||||
|
updateDocumentTitle(activeChannelRds());
|
||||||
|
}
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
async function vchanAllocate() {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
const freqHz = typeof lastFreqHz === "number" && lastFreqHz > 0 ? lastFreqHz : 0;
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
const mode = modeEl ? modeEl.value || "USB" : "USB";
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const msg = await resp.text().catch(() => String(resp.status));
|
||||||
|
console.warn("vchan: allocate failed —", msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ch = await resp.json();
|
||||||
|
vchanActiveId = ch.id;
|
||||||
|
vchanRender();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: allocate error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanDelete(channelId) {
|
||||||
|
if (!vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: delete failed", resp.status);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: delete error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: auto-join primary failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: auto-join error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanSubscribe(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: subscribe failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: subscribe error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanReconnectAudio() {
|
||||||
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||||
|
if (typeof _audioChannelOverride !== "undefined") {
|
||||||
|
_audioChannelOverride = ch ? ch.id : null;
|
||||||
|
}
|
||||||
|
if (typeof rxActive === "undefined" || !rxActive) return;
|
||||||
|
if (typeof stopRxAudio === "function") stopRxAudio();
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof startRxAudio === "function") startRxAudio();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
function vchanApplyCapabilities(caps) {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.style.display = caps && caps.filter_controls ? "" : "none";
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
function vchanIsOnVirtual() {
|
||||||
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||||
|
return vchanActiveId !== vchanChannels[0].id;
|
||||||
|
}
|
||||||
|
function vchanActiveChannel() {
|
||||||
|
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
|
||||||
|
}
|
||||||
|
function vchanUpdateFreqDisplay() {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const el = document.getElementById("freq");
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
|
||||||
|
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
|
||||||
|
} else {
|
||||||
|
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncModeDisplay() {
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
if (!modeEl) return;
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||||
|
}
|
||||||
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
|
if (typeof lastModeName !== "undefined") {
|
||||||
|
if (modeUpper === "WFM" && lastModeName !== "WFM") {
|
||||||
|
if (typeof setJogDivisor === "function") setJogDivisor(10);
|
||||||
|
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||||
|
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
|
||||||
|
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||||
|
}
|
||||||
|
lastModeName = modeUpper;
|
||||||
|
}
|
||||||
|
if (typeof updateWfmControls === "function") updateWfmControls();
|
||||||
|
if (typeof updateSdrSquelchControlVisibility === "function") {
|
||||||
|
updateSdrSquelchControlVisibility();
|
||||||
|
}
|
||||||
|
if (typeof refreshRdsUi === "function") {
|
||||||
|
refreshRdsUi();
|
||||||
|
} else if (typeof positionRdsPsOverlay === "function") {
|
||||||
|
positionRdsPsOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncBwDisplay() {
|
||||||
|
if (!vchanIsOnVirtual()) return;
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (!bwEl) return;
|
||||||
|
let bwHz = ch.bandwidth_hz || 0;
|
||||||
|
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
|
||||||
|
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
|
||||||
|
}
|
||||||
|
if (bwHz > 0) {
|
||||||
|
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
if (typeof currentBandwidthHz !== "undefined") {
|
||||||
|
currentBandwidthHz = bwHz;
|
||||||
|
window.currentBandwidthHz = bwHz;
|
||||||
|
} else {
|
||||||
|
window.currentBandwidthHz = bwHz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function vchanSyncAccentUI() {
|
||||||
|
const onVirtual = vchanIsOnVirtual();
|
||||||
|
const freqEl = document.getElementById("freq");
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (onVirtual) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanSyncBwDisplay();
|
||||||
|
} else if (typeof _origRefreshFreqDisplay === "function") {
|
||||||
|
_origRefreshFreqDisplay();
|
||||||
|
}
|
||||||
|
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||||
|
updateDocumentTitle(activeChannelRds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _origRefreshFreqDisplay = null;
|
||||||
|
function vchanSetChannelFreq(freqHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData && lastSpectrumData.sample_rate > 0) {
|
||||||
|
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
|
||||||
|
const center = Number(lastSpectrumData.center_hz);
|
||||||
|
if (Math.abs(freqHz - center) > halfSpan) {
|
||||||
|
if (typeof showHint === "function") {
|
||||||
|
showHint(
|
||||||
|
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||||
|
3e3
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vchanTakeSchedulerControl();
|
||||||
|
fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
|
||||||
|
}
|
||||||
|
).catch((e) => console.error("vchan: set freq error", e));
|
||||||
|
}
|
||||||
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set bw error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function vchanSetChannelMode(mode) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode })
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set mode error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.vchanInterceptMode = async function(mode) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelMode(mode);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
window.vchanInterceptBandwidth = async function(bwHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
(function() {
|
||||||
|
const _orig = window.setRigFrequency;
|
||||||
|
window.setRigFrequency = function(freqHz) {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
if (typeof applyLocalTunedFrequency === "function") {
|
||||||
|
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||||
|
++_freqOptimisticSeq;
|
||||||
|
_freqOptimisticHz = Math.round(freqHz);
|
||||||
|
}
|
||||||
|
applyLocalTunedFrequency(Math.round(freqHz));
|
||||||
|
}
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanTakeSchedulerControl();
|
||||||
|
if (typeof _orig === "function") _orig(freqHz);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
(function initSchedulerReleaseControl() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
vchanToggleSchedulerRelease();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vchanStartSchedulerReleasePolling();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
})();
|
||||||
|
(function() {
|
||||||
|
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
||||||
|
window.refreshFreqDisplay = function() {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"use strict";
|
||||||
|
const vdesStatus = document.getElementById("vdes-status");
|
||||||
|
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
|
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
|
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
|
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
|
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
|
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
|
const VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
|
let vdesFilterText = "";
|
||||||
|
let vdesMessageHistory = [];
|
||||||
|
function currentVdesHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneVdesMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||||
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleVdesUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function scheduleVdesHistoryRender() {
|
||||||
|
scheduleVdesUi("vdes-history", () => renderVdesHistory());
|
||||||
|
}
|
||||||
|
function scheduleVdesBarUpdate() {
|
||||||
|
scheduleVdesUi("vdes-bar", () => updateVdesBar());
|
||||||
|
}
|
||||||
|
function currentVdesCenterText() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const hz = raw ? Number(raw) : 0;
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||||
|
return `100 kHz @ ${(hz / 1e6).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
function vdesAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
function vdesHexPreview(rawBytes) {
|
||||||
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
|
return rawBytes.slice(0, 20).map((value) => Number(value).toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
|
}
|
||||||
|
function updateVdesSummary() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (vdesChannelSummaryEl) {
|
||||||
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
|
}
|
||||||
|
if (vdesFrameCountEl) {
|
||||||
|
const count = vdesMessageHistory.length;
|
||||||
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (vdesLatestSeenEl) {
|
||||||
|
const latest = vdesMessageHistory[0];
|
||||||
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function applyVdesFilterToRow(row) {
|
||||||
|
if (!vdesFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = row.dataset.filterText || "";
|
||||||
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function applyVdesFilterToAll() {
|
||||||
|
if (!vdesMessagesEl) return;
|
||||||
|
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||||
|
}
|
||||||
|
function renderVdesRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "vdes-message";
|
||||||
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
const title = msg.vessel_name || "VDES Burst";
|
||||||
|
const label = msg.callsign || "VDES";
|
||||||
|
const info = msg.destination || "";
|
||||||
|
const labelText = msg.message_label || "";
|
||||||
|
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||||
|
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||||
|
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||||
|
const fecText = msg.fec_state || "";
|
||||||
|
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
|
||||||
|
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
|
||||||
|
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
|
||||||
|
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
|
||||||
|
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
|
||||||
|
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
|
||||||
|
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
|
||||||
|
const previewText = msg.payload_preview || "";
|
||||||
|
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||||
|
row.dataset.filterText = [
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
labelText,
|
||||||
|
info,
|
||||||
|
srcText,
|
||||||
|
dstText,
|
||||||
|
sessionText,
|
||||||
|
asmText,
|
||||||
|
countText,
|
||||||
|
ackText,
|
||||||
|
cqiText,
|
||||||
|
previewText,
|
||||||
|
linkText,
|
||||||
|
syncText,
|
||||||
|
phaseText,
|
||||||
|
fecText,
|
||||||
|
rawHex,
|
||||||
|
msg.message_type,
|
||||||
|
msg.bit_len
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
row.innerHTML = `<div class="vdes-row-head"><span class="vdes-time">${ts}</span><span class="vdes-call">${escapeMapHtml(title)}</span><span class="vdes-badge">${escapeMapHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeMapHtml(currentVdesCenterText())}</span><span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeMapHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeMapHtml(info)}</span>` : "") + (fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") + `<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeMapHtml(rawHex)}</span></div>`;
|
||||||
|
applyVdesFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function updateVdesBar() {
|
||||||
|
if (!vdesBarOverlay) return;
|
||||||
|
updateVdesSummary();
|
||||||
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
|
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
|
||||||
|
if (!isVdes || messages.length === 0) {
|
||||||
|
vdesBarOverlay.style.display = "none";
|
||||||
|
vdesBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const label = escapeMapHtml(msg.callsign || "VDES");
|
||||||
|
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||||
|
const detail = [
|
||||||
|
`${msg.bit_len || 0} bits`,
|
||||||
|
msg.message_label ? escapeMapHtml(msg.message_label) : null,
|
||||||
|
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
|
||||||
|
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||||
|
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||||
|
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
|
||||||
|
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||||
|
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||||
|
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||||
|
escapeMapHtml(vdesAgeText(msg._tsMs))
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||||
|
}
|
||||||
|
vdesBarOverlay.innerHTML = html;
|
||||||
|
vdesBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateVdesBar = updateVdesBar;
|
||||||
|
window.clearVdesBar = function() {
|
||||||
|
window.resetVdesHistoryView();
|
||||||
|
};
|
||||||
|
window.resetVdesHistoryView = function() {
|
||||||
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
|
vdesMessageHistory = [];
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
};
|
||||||
|
function renderVdesHistory() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (!vdesMessagesEl) {
|
||||||
|
updateVdesSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
|
||||||
|
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
|
||||||
|
}
|
||||||
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
|
updateVdesSummary();
|
||||||
|
}
|
||||||
|
function addVdesMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
vdesMessageHistory.unshift(msg);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerVdesMessage(msg) {
|
||||||
|
return {
|
||||||
|
rig_id: msg.rig_id || null,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
bit_len: msg.bit_len,
|
||||||
|
raw_bytes: msg.raw_bytes,
|
||||||
|
lat: msg.lat,
|
||||||
|
lon: msg.lon,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
message_label: msg.message_label,
|
||||||
|
session_id: msg.session_id,
|
||||||
|
source_id: msg.source_id,
|
||||||
|
destination_id: msg.destination_id,
|
||||||
|
data_count: msg.data_count,
|
||||||
|
asm_identifier: msg.asm_identifier,
|
||||||
|
ack_nack_mask: msg.ack_nack_mask,
|
||||||
|
channel_quality: msg.channel_quality,
|
||||||
|
payload_preview: msg.payload_preview,
|
||||||
|
link_id: msg.link_id,
|
||||||
|
sync_score: msg.sync_score,
|
||||||
|
sync_errors: msg.sync_errors,
|
||||||
|
phase_rotation: msg.phase_rotation,
|
||||||
|
fec_state: msg.fec_state,
|
||||||
|
ts_ms: msg.ts_ms
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerVdesBatch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||||
|
window.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreVdesHistory = function(messages) {
|
||||||
|
window.onServerVdesBatch(messages);
|
||||||
|
};
|
||||||
|
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_vdes_decode");
|
||||||
|
window.resetVdesHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VDES history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (vdesFilterInput) {
|
||||||
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||||
|
renderVdesHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.onServerVdes = function(msg) {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
addVdesMessage(next);
|
||||||
|
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||||
|
window.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.pruneVdesHistoryView = function() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
};
|
||||||
|
updateVdesSummary();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
|
||||||
@@ -0,0 +1,485 @@
|
|||||||
|
"use strict";
|
||||||
|
(function initTrxWebGl(global) {
|
||||||
|
"use strict";
|
||||||
|
const cssColorCache = /* @__PURE__ */ new Map();
|
||||||
|
let cssColorProbe = null;
|
||||||
|
function clearCssColorCache() {
|
||||||
|
cssColorCache.clear();
|
||||||
|
}
|
||||||
|
function ensureCssColorProbe() {
|
||||||
|
if (cssColorProbe) return cssColorProbe;
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.style.position = "absolute";
|
||||||
|
el.style.left = "-9999px";
|
||||||
|
el.style.top = "-9999px";
|
||||||
|
el.style.pointerEvents = "none";
|
||||||
|
el.style.opacity = "0";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
cssColorProbe = el;
|
||||||
|
return cssColorProbe;
|
||||||
|
}
|
||||||
|
function parseRgbString(value) {
|
||||||
|
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const parts = m[1].split(",").map((p) => p.trim());
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const r = Number(parts[0]);
|
||||||
|
const g = Number(parts[1]);
|
||||||
|
const b = Number(parts[2]);
|
||||||
|
const a = parts.length > 3 ? Number(parts[3]) : 1;
|
||||||
|
if (![r, g, b, a].every(Number.isFinite)) return null;
|
||||||
|
return [
|
||||||
|
Math.max(0, Math.min(1, r / 255)),
|
||||||
|
Math.max(0, Math.min(1, g / 255)),
|
||||||
|
Math.max(0, Math.min(1, b / 255)),
|
||||||
|
Math.max(0, Math.min(1, a))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function parseHexColor(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
|
||||||
|
let hex = raw.slice(1);
|
||||||
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
|
hex = hex.split("").map((ch) => ch + ch).join("");
|
||||||
|
}
|
||||||
|
if (!(hex.length === 6 || hex.length === 8)) return null;
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||||
|
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||||
|
return [r, g, b, a];
|
||||||
|
}
|
||||||
|
function parseCssColor(value) {
|
||||||
|
const key = String(value ?? "");
|
||||||
|
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
|
||||||
|
let parsed = parseHexColor(key) || parseRgbString(key);
|
||||||
|
if (!parsed) {
|
||||||
|
const probe = ensureCssColorProbe();
|
||||||
|
probe.style.color = "";
|
||||||
|
probe.style.color = key;
|
||||||
|
const computed = getComputedStyle(probe).color;
|
||||||
|
parsed = parseRgbString(computed) || [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
cssColorCache.set(key, parsed.slice());
|
||||||
|
return parsed.slice();
|
||||||
|
}
|
||||||
|
function hslToRgba(h, s, l, a = 1) {
|
||||||
|
const hue = ((Number(h) || 0) % 360 + 360) % 360 / 360;
|
||||||
|
const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
|
||||||
|
const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
|
||||||
|
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
|
||||||
|
const p = 2 * lig - q;
|
||||||
|
const hueToRgb = (t) => {
|
||||||
|
let tt = t;
|
||||||
|
if (tt < 0) tt += 1;
|
||||||
|
if (tt > 1) tt -= 1;
|
||||||
|
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||||
|
if (tt < 1 / 2) return q;
|
||||||
|
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
|
||||||
|
const g = sat === 0 ? lig : hueToRgb(hue);
|
||||||
|
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
|
||||||
|
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
|
||||||
|
}
|
||||||
|
function normalizeColor(input, alphaMul = 1) {
|
||||||
|
let rgba;
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
const arr = input.map((v) => Number(v));
|
||||||
|
if (arr.length >= 4) {
|
||||||
|
rgba = [arr[0], arr[1], arr[2], arr[3]];
|
||||||
|
} else {
|
||||||
|
rgba = [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
} else if (typeof input === "string") {
|
||||||
|
rgba = parseCssColor(input);
|
||||||
|
} else if (input && typeof input === "object") {
|
||||||
|
rgba = [
|
||||||
|
Number(input.r) || 0,
|
||||||
|
Number(input.g) || 0,
|
||||||
|
Number(input.b) || 0,
|
||||||
|
Number(input.a ?? 1)
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
rgba = [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
const out = [
|
||||||
|
Math.max(0, Math.min(1, rgba[0])),
|
||||||
|
Math.max(0, Math.min(1, rgba[1])),
|
||||||
|
Math.max(0, Math.min(1, rgba[2])),
|
||||||
|
Math.max(0, Math.min(1, rgba[3] * alphaMul))
|
||||||
|
];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function compileShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
const log = gl.getShaderInfoLog(shader) || "shader compile error";
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
throw new Error(log);
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
function createProgram(gl, vertexSrc, fragmentSrc) {
|
||||||
|
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
|
||||||
|
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
gl.deleteShader(vs);
|
||||||
|
gl.deleteShader(fs);
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
const log = gl.getProgramInfoLog(program) || "program link error";
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
throw new Error(log);
|
||||||
|
}
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
function pushColoredVertex(target, x, y, rgba) {
|
||||||
|
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
|
}
|
||||||
|
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
|
||||||
|
const dx = x1 - x0;
|
||||||
|
const dy = y1 - y0;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (!(len > 1e-4)) return;
|
||||||
|
const nx = -dy / len * halfW;
|
||||||
|
const ny = dx / len * halfW;
|
||||||
|
const ax = x0 - nx, ay = y0 - ny;
|
||||||
|
const bx = x0 + nx, by = y0 + ny;
|
||||||
|
const cx = x1 + nx, cy = y1 + ny;
|
||||||
|
const dx2 = x1 - nx, dy2 = y1 - ny;
|
||||||
|
pushColoredVertex(out, ax, ay, rgba);
|
||||||
|
pushColoredVertex(out, bx, by, rgba);
|
||||||
|
pushColoredVertex(out, cx, cy, rgba);
|
||||||
|
pushColoredVertex(out, ax, ay, rgba);
|
||||||
|
pushColoredVertex(out, cx, cy, rgba);
|
||||||
|
pushColoredVertex(out, dx2, dy2, rgba);
|
||||||
|
}
|
||||||
|
class TrxWebGlRenderer {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.options = { alpha: true, premultipliedAlpha: false, ...options };
|
||||||
|
this.gl = canvas?.getContext("webgl", this.options) || canvas?.getContext("experimental-webgl", this.options) || null;
|
||||||
|
this.ready = !!this.gl;
|
||||||
|
this.textures = /* @__PURE__ */ new Map();
|
||||||
|
this._colorScratch = new Float32Array(4096 * 6);
|
||||||
|
this._colorGpuSize = 0;
|
||||||
|
this._texScratch = new Float32Array(6 * 4);
|
||||||
|
if (!this.ready) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
gl.disable(gl.DEPTH_TEST);
|
||||||
|
gl.disable(gl.CULL_FACE);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
const colorVertexSrc = "attribute vec2 a_pos;\nattribute vec4 a_color;\nuniform vec2 u_resolution;\nvarying vec4 v_color;\nvoid main() {\n vec2 zeroToOne = a_pos / u_resolution;\n vec2 clip = zeroToOne * 2.0 - 1.0;\n gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n v_color = a_color;\n}\n";
|
||||||
|
const colorFragmentSrc = "precision mediump float;\nvarying vec4 v_color;\nvoid main() {\n gl_FragColor = v_color;\n}\n";
|
||||||
|
const textureVertexSrc = "attribute vec2 a_pos;\nattribute vec2 a_uv;\nuniform vec2 u_resolution;\nvarying vec2 v_uv;\nvoid main() {\n vec2 zeroToOne = a_pos / u_resolution;\n vec2 clip = zeroToOne * 2.0 - 1.0;\n gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n v_uv = a_uv;\n}\n";
|
||||||
|
const textureFragmentSrc = "precision mediump float;\nvarying vec2 v_uv;\nuniform sampler2D u_tex;\nuniform float u_alpha;\nvoid main() {\n vec4 c = texture2D(u_tex, v_uv);\n gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n}\n";
|
||||||
|
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
|
||||||
|
this.colorBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||||
|
this._colorGpuSize = this._colorScratch.length;
|
||||||
|
this.colorLoc = {
|
||||||
|
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
|
||||||
|
color: gl.getAttribLocation(this.colorProgram, "a_color"),
|
||||||
|
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution")
|
||||||
|
};
|
||||||
|
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
|
||||||
|
this.textureBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
|
||||||
|
this.textureLoc = {
|
||||||
|
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
|
||||||
|
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
|
||||||
|
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
|
||||||
|
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
|
||||||
|
tex: gl.getUniformLocation(this.textureProgram, "u_tex")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
ensureSize(cssWidth, cssHeight, dpr = window.devicePixelRatio || 1) {
|
||||||
|
if (!this.ready) return false;
|
||||||
|
const nextW = Math.max(1, Math.round(cssWidth * dpr));
|
||||||
|
const nextH = Math.max(1, Math.round(cssHeight * dpr));
|
||||||
|
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
|
||||||
|
if (changed) {
|
||||||
|
this.canvas.width = nextW;
|
||||||
|
this.canvas.height = nextH;
|
||||||
|
}
|
||||||
|
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
clear(color) {
|
||||||
|
if (!this.ready) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
drawTriangles(vertices) {
|
||||||
|
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
drawTriangleStrip(vertices) {
|
||||||
|
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
|
||||||
|
}
|
||||||
|
_drawColorGeometry(vertices, mode) {
|
||||||
|
if (!this.ready || !vertices || vertices.length === 0) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const count = vertices.length;
|
||||||
|
if (count > this._colorScratch.length) {
|
||||||
|
let newLen = this._colorScratch.length;
|
||||||
|
while (newLen < count) newLen *= 2;
|
||||||
|
this._colorScratch = new Float32Array(newLen);
|
||||||
|
}
|
||||||
|
this._colorScratch.set(vertices);
|
||||||
|
const view = this._colorScratch.subarray(0, count);
|
||||||
|
gl.useProgram(this.colorProgram);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||||
|
if (count > this._colorGpuSize) {
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||||
|
this._colorGpuSize = this._colorScratch.length;
|
||||||
|
} else {
|
||||||
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
|
||||||
|
}
|
||||||
|
gl.enableVertexAttribArray(this.colorLoc.pos);
|
||||||
|
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
|
||||||
|
gl.enableVertexAttribArray(this.colorLoc.color);
|
||||||
|
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
|
||||||
|
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
|
||||||
|
gl.drawArrays(mode, 0, count / 6);
|
||||||
|
}
|
||||||
|
fillRect(x, y, w, h, color) {
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const v = [];
|
||||||
|
pushColoredVertex(v, x, y, rgba);
|
||||||
|
pushColoredVertex(v, x + w, y, rgba);
|
||||||
|
pushColoredVertex(v, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(v, x, y, rgba);
|
||||||
|
pushColoredVertex(v, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(v, x, y + h, rgba);
|
||||||
|
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
const tl = normalizeColor(colorTL);
|
||||||
|
const tr = normalizeColor(colorTR);
|
||||||
|
const br = normalizeColor(colorBR);
|
||||||
|
const bl = normalizeColor(colorBL);
|
||||||
|
const v = [];
|
||||||
|
pushColoredVertex(v, x, y, tl);
|
||||||
|
pushColoredVertex(v, x + w, y, tr);
|
||||||
|
pushColoredVertex(v, x + w, y + h, br);
|
||||||
|
pushColoredVertex(v, x, y, tl);
|
||||||
|
pushColoredVertex(v, x + w, y + h, br);
|
||||||
|
pushColoredVertex(v, x, y + h, bl);
|
||||||
|
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
drawPolyline(points, color, width = 1) {
|
||||||
|
if (!Array.isArray(points) || points.length < 4) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < points.length - 2; i += 2) {
|
||||||
|
segmentToQuadVertices(
|
||||||
|
verts,
|
||||||
|
points[i],
|
||||||
|
points[i + 1],
|
||||||
|
points[i + 2],
|
||||||
|
points[i + 3],
|
||||||
|
halfW,
|
||||||
|
rgba
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
drawSegments(segments, color, width = 1) {
|
||||||
|
if (!Array.isArray(segments) || segments.length < 4) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < segments.length - 3; i += 4) {
|
||||||
|
segmentToQuadVertices(
|
||||||
|
verts,
|
||||||
|
segments[i],
|
||||||
|
segments[i + 1],
|
||||||
|
segments[i + 2],
|
||||||
|
segments[i + 3],
|
||||||
|
halfW,
|
||||||
|
rgba
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
drawFilledArea(points, baselineY, color) {
|
||||||
|
if (!Array.isArray(points) || points.length < 4) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
pushColoredVertex(verts, points[i], baselineY, rgba);
|
||||||
|
pushColoredVertex(verts, points[i], points[i + 1], rgba);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
|
||||||
|
}
|
||||||
|
drawPoints(points, size, color) {
|
||||||
|
if (!Array.isArray(points) || points.length < 2) return;
|
||||||
|
const radius = Math.max(1, Number(size) || 1);
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
const x = points[i] - radius;
|
||||||
|
const y = points[i + 1] - radius;
|
||||||
|
const w = radius * 2;
|
||||||
|
const h = radius * 2;
|
||||||
|
pushColoredVertex(verts, x, y, rgba);
|
||||||
|
pushColoredVertex(verts, x + w, y, rgba);
|
||||||
|
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(verts, x, y, rgba);
|
||||||
|
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(verts, x, y + h, rgba);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
|
||||||
|
const dash = Math.max(1, Number(dashLen) || 1);
|
||||||
|
const gap = Math.max(1, Number(gapLen) || 1);
|
||||||
|
const top = Math.min(y0, y1);
|
||||||
|
const bottom = Math.max(y0, y1);
|
||||||
|
const segments = [];
|
||||||
|
for (let y = top; y < bottom; y += dash + gap) {
|
||||||
|
const segEnd = Math.min(bottom, y + dash);
|
||||||
|
segments.push(x, y, x, segEnd);
|
||||||
|
}
|
||||||
|
this.drawSegments(segments, color, width);
|
||||||
|
}
|
||||||
|
uploadRgbaTexture(name, width, height, data, filter = "linear") {
|
||||||
|
if (!this.ready || !name || !data) return null;
|
||||||
|
const gl = this.gl;
|
||||||
|
let entry = this.textures.get(name);
|
||||||
|
if (!entry) {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
entry = { texture, width: 0, height: 0 };
|
||||||
|
this.textures.set(name, entry);
|
||||||
|
}
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||||
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||||
|
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
if (entry.width !== width || entry.height !== height) {
|
||||||
|
gl.texImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
entry.width = width;
|
||||||
|
entry.height = height;
|
||||||
|
} else {
|
||||||
|
gl.texSubImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
gl.RGBA,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entry.texture;
|
||||||
|
}
|
||||||
|
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
|
||||||
|
if (!this.ready || !name || w <= 0 || h <= 0) return;
|
||||||
|
const entry = this.textures.get(name);
|
||||||
|
if (!entry) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const s = this._texScratch;
|
||||||
|
const x2 = x + w, y2 = y + h;
|
||||||
|
if (flipY) {
|
||||||
|
s[0] = x;
|
||||||
|
s[1] = y;
|
||||||
|
s[2] = 0;
|
||||||
|
s[3] = 1;
|
||||||
|
s[4] = x2;
|
||||||
|
s[5] = y;
|
||||||
|
s[6] = 1;
|
||||||
|
s[7] = 1;
|
||||||
|
s[8] = x2;
|
||||||
|
s[9] = y2;
|
||||||
|
s[10] = 1;
|
||||||
|
s[11] = 0;
|
||||||
|
s[12] = x;
|
||||||
|
s[13] = y;
|
||||||
|
s[14] = 0;
|
||||||
|
s[15] = 1;
|
||||||
|
s[16] = x2;
|
||||||
|
s[17] = y2;
|
||||||
|
s[18] = 1;
|
||||||
|
s[19] = 0;
|
||||||
|
s[20] = x;
|
||||||
|
s[21] = y2;
|
||||||
|
s[22] = 0;
|
||||||
|
s[23] = 0;
|
||||||
|
} else {
|
||||||
|
s[0] = x;
|
||||||
|
s[1] = y;
|
||||||
|
s[2] = 0;
|
||||||
|
s[3] = 0;
|
||||||
|
s[4] = x2;
|
||||||
|
s[5] = y;
|
||||||
|
s[6] = 1;
|
||||||
|
s[7] = 0;
|
||||||
|
s[8] = x2;
|
||||||
|
s[9] = y2;
|
||||||
|
s[10] = 1;
|
||||||
|
s[11] = 1;
|
||||||
|
s[12] = x;
|
||||||
|
s[13] = y;
|
||||||
|
s[14] = 0;
|
||||||
|
s[15] = 0;
|
||||||
|
s[16] = x2;
|
||||||
|
s[17] = y2;
|
||||||
|
s[18] = 1;
|
||||||
|
s[19] = 1;
|
||||||
|
s[20] = x;
|
||||||
|
s[21] = y2;
|
||||||
|
s[22] = 0;
|
||||||
|
s[23] = 1;
|
||||||
|
}
|
||||||
|
gl.useProgram(this.textureProgram);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||||
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
|
||||||
|
gl.enableVertexAttribArray(this.textureLoc.pos);
|
||||||
|
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
|
||||||
|
gl.enableVertexAttribArray(this.textureLoc.uv);
|
||||||
|
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
|
||||||
|
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
|
||||||
|
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||||
|
gl.uniform1i(this.textureLoc.tex, 0);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function createRenderer(canvas, options) {
|
||||||
|
return new TrxWebGlRenderer(canvas, options);
|
||||||
|
}
|
||||||
|
global.trxParseCssColor = parseCssColor;
|
||||||
|
global.trxHslToRgba = hslToRgba;
|
||||||
|
global.createTrxWebGlRenderer = createRenderer;
|
||||||
|
global.trxClearCssColorCache = clearCssColorCache;
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use strict";
|
||||||
|
var wefaxDom = {
|
||||||
|
status: document.getElementById("wefax-status"),
|
||||||
|
liveView: document.getElementById("wefax-live-view"),
|
||||||
|
historyView: document.getElementById("wefax-history-view"),
|
||||||
|
liveContainer: document.getElementById("wefax-live-container"),
|
||||||
|
liveInfo: document.getElementById("wefax-live-info"),
|
||||||
|
liveCanvas: document.getElementById("wefax-live-canvas"),
|
||||||
|
liveLatest: document.getElementById("wefax-live-latest"),
|
||||||
|
historyList: document.getElementById("wefax-history-list"),
|
||||||
|
historyCount: document.getElementById("wefax-history-count"),
|
||||||
|
filterInput: document.getElementById("wefax-filter"),
|
||||||
|
sortSelect: document.getElementById("wefax-sort"),
|
||||||
|
toggleBtn: document.getElementById("wefax-decode-toggle-btn"),
|
||||||
|
clearBtn: document.getElementById("wefax-clear-btn"),
|
||||||
|
viewLiveBtn: document.getElementById("wefax-view-live"),
|
||||||
|
viewHistoryBtn: document.getElementById("wefax-view-history")
|
||||||
|
};
|
||||||
|
var wefaxImageHistory = [];
|
||||||
|
var WEFAX_MAX_IMAGES = 100;
|
||||||
|
var wefaxLiveCtx = null;
|
||||||
|
var wefaxLiveLineCount = 0;
|
||||||
|
var wefaxLivePixelsPerLine = 1809;
|
||||||
|
var wefaxActiveView = "live";
|
||||||
|
var wefaxFilterText = "";
|
||||||
|
function currentWefaxHistoryRetentionMs() {
|
||||||
|
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneWefaxHistory() {
|
||||||
|
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||||
|
wefaxImageHistory = wefaxImageHistory.filter(function(m) {
|
||||||
|
return (m._tsMs || 0) > cutoff;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleWefaxUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
function switchWefaxView(view) {
|
||||||
|
wefaxActiveView = view;
|
||||||
|
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
|
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
|
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function(btn) {
|
||||||
|
if (btn) btn.classList.remove("sat-view-active");
|
||||||
|
});
|
||||||
|
if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active");
|
||||||
|
if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active");
|
||||||
|
if (view === "history") renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
|
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("live");
|
||||||
|
});
|
||||||
|
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("history");
|
||||||
|
});
|
||||||
|
function resetLiveCanvas(pixelsPerLine) {
|
||||||
|
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
wefaxDom.liveCanvas.width = pixelsPerLine;
|
||||||
|
wefaxDom.liveCanvas.height = 800;
|
||||||
|
wefaxLiveCtx = wefaxDom.liveCanvas.getContext("2d");
|
||||||
|
wefaxLiveCtx.fillStyle = "#000";
|
||||||
|
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "";
|
||||||
|
}
|
||||||
|
function paintLine(lineBytes) {
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
var y = wefaxLiveLineCount;
|
||||||
|
if (y >= wefaxDom.liveCanvas.height) {
|
||||||
|
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||||
|
wefaxDom.liveCanvas.height *= 2;
|
||||||
|
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||||
|
}
|
||||||
|
var w = wefaxLivePixelsPerLine;
|
||||||
|
var imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||||
|
var d = imgData.data;
|
||||||
|
for (var x = 0; x < w; x++) {
|
||||||
|
var v = x < lineBytes.length ? lineBytes[x] : 0;
|
||||||
|
var i = x * 4;
|
||||||
|
d[i] = v;
|
||||||
|
d[i + 1] = v;
|
||||||
|
d[i + 2] = v;
|
||||||
|
d[i + 3] = 255;
|
||||||
|
}
|
||||||
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
|
wefaxLiveLineCount++;
|
||||||
|
}
|
||||||
|
function renderWefaxLatestCard() {
|
||||||
|
if (!wefaxDom.liveLatest) return;
|
||||||
|
if (wefaxImageHistory.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>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var img = wefaxImageHistory[0];
|
||||||
|
var ts = img._ts || "--";
|
||||||
|
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
var meta = [
|
||||||
|
img.ioc + " IOC",
|
||||||
|
img.lpm + " LPM",
|
||||||
|
img.line_count + " lines",
|
||||||
|
date + " " + ts
|
||||||
|
].join(" · ");
|
||||||
|
var imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
var html = '<div class="sat-latest-card">';
|
||||||
|
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||||
|
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + "</div>";
|
||||||
|
if (imgSrc) {
|
||||||
|
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
||||||
|
}
|
||||||
|
html += "</div>";
|
||||||
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
function getWefaxFilteredHistory() {
|
||||||
|
var items = wefaxImageHistory;
|
||||||
|
if (wefaxFilterText) {
|
||||||
|
items = items.filter(function(i) {
|
||||||
|
var haystack = [
|
||||||
|
String(i.ioc || ""),
|
||||||
|
String(i.lpm || ""),
|
||||||
|
String(i.line_count || "")
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryRow(img) {
|
||||||
|
var row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
var ts = img._ts || "--";
|
||||||
|
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
var ioc = img.ioc || "--";
|
||||||
|
var lpm = img.lpm || "--";
|
||||||
|
var lines = img.line_count || 0;
|
||||||
|
var imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
var link = imgSrc ? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>' : "--";
|
||||||
|
row.innerHTML = [
|
||||||
|
"<span>" + escapeHtml(date + " " + ts) + "</span>",
|
||||||
|
"<span>" + escapeHtml(String(ioc)) + "</span>",
|
||||||
|
"<span>" + escapeHtml(String(lpm)) + "</span>",
|
||||||
|
"<span>" + lines + "</span>",
|
||||||
|
"<span>" + link + "</span>"
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryTable() {
|
||||||
|
if (!wefaxDom.historyList) return;
|
||||||
|
pruneWefaxHistory();
|
||||||
|
var items = getWefaxFilteredHistory();
|
||||||
|
var fragment = document.createDocumentFragment();
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
fragment.appendChild(renderWefaxHistoryRow(items[i]));
|
||||||
|
}
|
||||||
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
if (wefaxDom.historyCount) {
|
||||||
|
var total = wefaxImageHistory.length;
|
||||||
|
var shown = items.length;
|
||||||
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? total + " image" + (total === 1 ? "" : "s") : shown + " of " + total + " images";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addWefaxImage(msg) {
|
||||||
|
var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
|
||||||
|
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
|
||||||
|
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
|
||||||
|
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||||
|
try {
|
||||||
|
msg._dataUrl = wefaxDom.liveCanvas.toDataURL("image/png");
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wefaxImageHistory.unshift(msg);
|
||||||
|
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||||
|
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.onServerWefaxProgress = function(msg) {
|
||||||
|
if (msg.state && !msg.line_data) {
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = msg.state;
|
||||||
|
wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)";
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (msg.line_count <= 1 || !wefaxLiveCtx) {
|
||||||
|
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||||
|
}
|
||||||
|
if (msg.line_data) {
|
||||||
|
var binary = atob(msg.line_data);
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
paintLine(bytes);
|
||||||
|
}
|
||||||
|
if (wefaxDom.liveInfo) {
|
||||||
|
wefaxDom.liveInfo.textContent = "Line " + msg.line_count + " · " + msg.ioc + " IOC · " + msg.lpm + " LPM";
|
||||||
|
}
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = "Receiving — line " + msg.line_count;
|
||||||
|
wefaxDom.status.style.color = "var(--text-accent)";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.onServerWefax = function(msg) {
|
||||||
|
addWefaxImage(msg);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = "Complete — " + msg.line_count + " lines";
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.restoreWefaxHistory = function(messages) {
|
||||||
|
if (!messages || !messages.length) return;
|
||||||
|
for (var i = 0; i < messages.length; i++) {
|
||||||
|
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
|
||||||
|
messages[i]._tsMs = tsMs;
|
||||||
|
messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||||
|
pruneWefaxHistory();
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.pruneWefaxHistoryView = function() {
|
||||||
|
pruneWefaxHistory();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
};
|
||||||
|
window.resetWefaxHistoryView = function() {
|
||||||
|
wefaxImageHistory = [];
|
||||||
|
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = "";
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
wefaxLiveCtx = null;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = "Idle";
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (wefaxDom.filterInput) {
|
||||||
|
wefaxDom.filterInput.addEventListener("input", function() {
|
||||||
|
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.sortSelect) {
|
||||||
|
wefaxDom.sortSelect.addEventListener("change", function() {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
window.syncWefaxToggle = function(enabled) {
|
||||||
|
if (!wefaxDom.toggleBtn) return;
|
||||||
|
wefaxDom.toggleBtn.dataset.enabled = enabled ? "true" : "false";
|
||||||
|
wefaxDom.toggleBtn.textContent = enabled ? "Disable WEFAX" : "Enable WEFAX";
|
||||||
|
wefaxDom.toggleBtn.style.borderColor = enabled ? "#00d17f" : "";
|
||||||
|
wefaxDom.toggleBtn.style.color = enabled ? "#00d17f" : "";
|
||||||
|
};
|
||||||
|
if (wefaxDom.toggleBtn) {
|
||||||
|
wefaxDom.toggleBtn.addEventListener("click", async function() {
|
||||||
|
try {
|
||||||
|
if (window.takeSchedulerControlForDecoderDisable) {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await postPath("/toggle_wefax_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.clearBtn) {
|
||||||
|
wefaxDom.clearBtn.addEventListener("click", async function() {
|
||||||
|
try {
|
||||||
|
await postPath("/clear_wefax_decode");
|
||||||
|
window.resetWefaxHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderWefaxLatestCard();
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
"use strict";
|
||||||
|
const wsprStatus = document.getElementById("wspr-status");
|
||||||
|
const wsprPeriodEl = document.getElementById("wspr-period");
|
||||||
|
const wsprMessagesEl = document.getElementById("wspr-messages");
|
||||||
|
const wsprFilterInput = document.getElementById("wspr-filter");
|
||||||
|
const WSPR_PERIOD_SECONDS = 120;
|
||||||
|
let wsprFilterText = "";
|
||||||
|
let wsprMessageHistory = [];
|
||||||
|
function currentWsprHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneWsprMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||||
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleWsprHistoryRender() {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderWsprHistory();
|
||||||
|
}
|
||||||
|
function fmtWsprTime(tsMs) {
|
||||||
|
if (!tsMs) return "--:--:--";
|
||||||
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
function updateWsprPeriodTimer() {
|
||||||
|
if (!wsprPeriodEl) return;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1e3);
|
||||||
|
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
||||||
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
updateWsprPeriodTimer();
|
||||||
|
setInterval(updateWsprPeriodTimer, 500);
|
||||||
|
function renderWsprRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
row.dataset.decoder = "wspr";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
||||||
|
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? baseHz + msg.freq_hz : null;
|
||||||
|
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
||||||
|
const message = (msg.message || "").toString();
|
||||||
|
row.dataset.message = message.toUpperCase();
|
||||||
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
|
applyWsprFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWsprHistory() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
if (!wsprMessagesEl) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
|
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
||||||
|
}
|
||||||
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
function addWsprMessage(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
wsprMessageHistory.unshift(msg);
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
scheduleWsprHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerWsprMessage(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const grids = extractAllGrids(raw);
|
||||||
|
const station = extractLikelyCallsign(raw);
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? baseHz + Number(msg.freq_hz) : Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null;
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: msg.freq_hz,
|
||||||
|
message: raw
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
window.onServerWsprBatch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
wsprStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
scheduleWsprHistoryRender();
|
||||||
|
};
|
||||||
|
window.restoreWsprHistory = function(messages) {
|
||||||
|
window.onServerWsprBatch(messages);
|
||||||
|
};
|
||||||
|
window.pruneWsprHistoryView = function() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
renderWsprHistory();
|
||||||
|
};
|
||||||
|
function escapeWsprHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function renderWsprMessage(message) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
const ch = message[i];
|
||||||
|
if (isAlphaNum(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (isMaidenheadGridToken(grid)) {
|
||||||
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function extractAllGrids(message) {
|
||||||
|
const out = [];
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
||||||
|
seen.add(token);
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function extractLikelyCallsign(message) {
|
||||||
|
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.length < 3 || token.length > 12) continue;
|
||||||
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
||||||
|
if (isMaidenheadGridToken(token)) continue;
|
||||||
|
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function isFtxFarewellToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
|
}
|
||||||
|
function isMaidenheadGridToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||||
|
}
|
||||||
|
function isAlphaNum(ch) {
|
||||||
|
return /[A-Za-z0-9]/.test(ch);
|
||||||
|
}
|
||||||
|
function activateWsprHistoryLocator(targetEl) {
|
||||||
|
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||||
|
if (!locatorEl) return false;
|
||||||
|
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
|
if (!grid) return false;
|
||||||
|
if (typeof window.navigateToMapLocator === "function") {
|
||||||
|
window.navigateToMapLocator(grid, "wspr");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function applyWsprFilterToRow(row) {
|
||||||
|
if (!wsprFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.message || "";
|
||||||
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function applyWsprFilterToAll() {
|
||||||
|
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => applyWsprFilterToRow(row));
|
||||||
|
}
|
||||||
|
window.resetWsprHistoryView = function() {
|
||||||
|
wsprMessagesEl.innerHTML = "";
|
||||||
|
wsprMessageHistory = [];
|
||||||
|
renderWsprHistory();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
||||||
|
};
|
||||||
|
if (wsprFilterInput) {
|
||||||
|
wsprFilterInput.addEventListener("input", () => {
|
||||||
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||||
|
renderWsprHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wsprMessagesEl) {
|
||||||
|
wsprMessagesEl.addEventListener("click", (event) => {
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
wsprMessagesEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||||
|
wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||||
|
await postPath("/toggle_wspr_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WSPR toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_wspr_decode");
|
||||||
|
window.resetWsprHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WSPR history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
window.onServerWspr = function(msg) {
|
||||||
|
wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addWsprMessage(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { build } from "esbuild";
|
||||||
|
import { rm } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const frontendDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const sourceDir = path.join(frontendDir, "src");
|
||||||
|
const outputDir = path.join(frontendDir, "..", "assets", "web", "generated");
|
||||||
|
|
||||||
|
await rm(outputDir, { recursive: true, force: true });
|
||||||
|
|
||||||
|
await build({
|
||||||
|
entryPoints: {
|
||||||
|
app: path.join(sourceDir, "app.js"),
|
||||||
|
"ui-core": path.join(sourceDir, "ui-core.js"),
|
||||||
|
"map-core": path.join(sourceDir, "map-core.js"),
|
||||||
|
screenshot: path.join(sourceDir, "screenshot.js"),
|
||||||
|
"webgl-renderer": path.join(sourceDir, "webgl-renderer.js"),
|
||||||
|
"decode-history-worker": path.join(sourceDir, "decode-history-worker.js"),
|
||||||
|
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"),
|
||||||
|
ais: path.join(sourceDir, "plugins", "ais.js"),
|
||||||
|
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
||||||
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
|
||||||
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
|
||||||
|
cw: path.join(sourceDir, "plugins", "cw.js"),
|
||||||
|
ft2: path.join(sourceDir, "plugins", "ft2.js"),
|
||||||
|
ft4: path.join(sourceDir, "plugins", "ft4.js"),
|
||||||
|
ft8: path.join(sourceDir, "plugins", "ft8.js"),
|
||||||
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
|
||||||
|
sat: path.join(sourceDir, "plugins", "sat.js"),
|
||||||
|
"sat-scheduler": path.join(sourceDir, "plugins", "sat-scheduler.js"),
|
||||||
|
scheduler: path.join(sourceDir, "plugins", "scheduler.js"),
|
||||||
|
vchan: path.join(sourceDir, "plugins", "vchan.js"),
|
||||||
|
vdes: path.join(sourceDir, "plugins", "vdes.js"),
|
||||||
|
wefax: path.join(sourceDir, "plugins", "wefax.js"),
|
||||||
|
wspr: path.join(sourceDir, "plugins", "wspr.js"),
|
||||||
|
},
|
||||||
|
outdir: outputDir,
|
||||||
|
bundle: false,
|
||||||
|
target: "es2022",
|
||||||
|
sourcemap: false,
|
||||||
|
legalComments: "inline",
|
||||||
|
charset: "utf8",
|
||||||
|
logLevel: "info",
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import eslint from "@eslint/js";
|
||||||
|
import globals from "globals";
|
||||||
|
import tseslint from "typescript-eslint";
|
||||||
|
|
||||||
|
export default tseslint.config(
|
||||||
|
{
|
||||||
|
ignores: [
|
||||||
|
"../assets/web/generated/**",
|
||||||
|
// Removed file-by-file as the legacy sources are converted to TypeScript.
|
||||||
|
"src/**/*.js",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.mjs"],
|
||||||
|
...eslint.configs.recommended,
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.node,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...tseslint.configs.strictTypeChecked.map((config) => ({
|
||||||
|
...config,
|
||||||
|
files: ["src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
})),
|
||||||
|
{
|
||||||
|
files: ["src/**/*.ts", "tests/**/*.ts"],
|
||||||
|
languageOptions: {
|
||||||
|
globals: globals.browser,
|
||||||
|
parserOptions: {
|
||||||
|
projectService: true,
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
"no-undef": "off",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "trx-frontend-http-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "node build.mjs",
|
||||||
|
"typecheck": "tsc --project tsconfig.json",
|
||||||
|
"lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern",
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"verify-generated": "npm run build && git diff --exit-code -- ../assets/web/generated"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "9.39.2",
|
||||||
|
"esbuild": "0.25.12",
|
||||||
|
"eslint": "9.39.2",
|
||||||
|
"globals": "16.5.0",
|
||||||
|
"typescript": "5.9.3",
|
||||||
|
"typescript-eslint": "8.51.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<!--
|
||||||
|
SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
|
||||||
|
SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
-->
|
||||||
|
|
||||||
|
# Frontend source
|
||||||
|
|
||||||
|
This directory is the source of the browser assets embedded by
|
||||||
|
`trx-frontend-http`. Run `npm run build` from the parent `frontend` directory
|
||||||
|
after changing a source file. Cargo consumes the committed output under
|
||||||
|
`../assets/web/generated` and does not invoke Node.js.
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,180 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
const textDecoder = typeof TextDecoder === "function" ? new TextDecoder() : null;
|
||||||
|
const HISTORY_GROUP_KEYS = ["ais", "vdes", "aprs", "hf_aprs", "cw", "ft8", "ft4", "ft2", "wspr", "wefax"];
|
||||||
|
|
||||||
|
function decodeCborUint(view, bytes, state, additional) {
|
||||||
|
const offset = state.offset;
|
||||||
|
if (additional < 24) return additional;
|
||||||
|
if (additional === 24) {
|
||||||
|
if (offset + 1 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 1;
|
||||||
|
return bytes[offset];
|
||||||
|
}
|
||||||
|
if (additional === 25) {
|
||||||
|
if (offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 2;
|
||||||
|
return view.getUint16(offset);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
state.offset += 4;
|
||||||
|
return view.getUint32(offset);
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getBigUint64(offset);
|
||||||
|
state.offset += 8;
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isSafeInteger(numeric)) throw new Error("CBOR integer exceeds JS safe range");
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR additional info");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCborFloat16(bits) {
|
||||||
|
const sign = (bits & 0x8000) ? -1 : 1;
|
||||||
|
const exponent = (bits >> 10) & 0x1f;
|
||||||
|
const fraction = bits & 0x03ff;
|
||||||
|
if (exponent === 0) {
|
||||||
|
return fraction === 0 ? sign * 0 : sign * Math.pow(2, -14) * (fraction / 1024);
|
||||||
|
}
|
||||||
|
if (exponent === 0x1f) {
|
||||||
|
return fraction === 0 ? sign * Infinity : Number.NaN;
|
||||||
|
}
|
||||||
|
return sign * Math.pow(2, exponent - 15) * (1 + (fraction / 1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCborItem(view, bytes, state) {
|
||||||
|
if (state.offset >= bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const initial = bytes[state.offset++];
|
||||||
|
const major = initial >> 5;
|
||||||
|
const additional = initial & 0x1f;
|
||||||
|
if (major === 0) return decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 1) return -1 - decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (major === 2) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.slice(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return Array.from(chunk);
|
||||||
|
}
|
||||||
|
if (major === 3) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
if (state.offset + length > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const chunk = bytes.subarray(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return textDecoder ? textDecoder.decode(chunk) : String.fromCharCode(...chunk);
|
||||||
|
}
|
||||||
|
if (major === 4) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const items = new Array(length);
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
items[i] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
if (major === 5) {
|
||||||
|
const length = decodeCborUint(view, bytes, state, additional);
|
||||||
|
const value = {};
|
||||||
|
for (let i = 0; i < length; i += 1) {
|
||||||
|
const key = decodeCborItem(view, bytes, state);
|
||||||
|
value[String(key)] = decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (major === 6) {
|
||||||
|
decodeCborUint(view, bytes, state, additional);
|
||||||
|
return decodeCborItem(view, bytes, state);
|
||||||
|
}
|
||||||
|
if (major === 7) {
|
||||||
|
if (additional === 20) return false;
|
||||||
|
if (additional === 21) return true;
|
||||||
|
if (additional === 22) return null;
|
||||||
|
if (additional === 23) return undefined;
|
||||||
|
if (additional === 25) {
|
||||||
|
if (state.offset + 2 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const bits = view.getUint16(state.offset);
|
||||||
|
state.offset += 2;
|
||||||
|
return decodeCborFloat16(bits);
|
||||||
|
}
|
||||||
|
if (additional === 26) {
|
||||||
|
if (state.offset + 4 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat32(state.offset);
|
||||||
|
state.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (additional === 27) {
|
||||||
|
if (state.offset + 8 > bytes.length) throw new Error("CBOR payload truncated");
|
||||||
|
const value = view.getFloat64(state.offset);
|
||||||
|
state.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("Unsupported CBOR major type");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeCborPayload(buffer) {
|
||||||
|
const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
|
||||||
|
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
|
const state = { offset: 0 };
|
||||||
|
const value = decodeCborItem(view, bytes, state);
|
||||||
|
if (state.offset !== bytes.length) {
|
||||||
|
throw new Error("Unexpected trailing bytes in decode history payload");
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAndDecodeHistory(url, batchLimit) {
|
||||||
|
self.postMessage({ type: "status", phase: "fetching" });
|
||||||
|
const resp = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (!resp.ok) throw new Error(`History fetch failed: ${resp.status}`);
|
||||||
|
const payload = await resp.arrayBuffer();
|
||||||
|
if (!payload || payload.byteLength === 0) {
|
||||||
|
self.postMessage({ type: "start", total: 0 });
|
||||||
|
self.postMessage({ type: "done", total: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.postMessage({ type: "status", phase: "decoding" });
|
||||||
|
const history = decodeCborPayload(payload);
|
||||||
|
const total = HISTORY_GROUP_KEYS.reduce((sum, key) => {
|
||||||
|
const items = history && Array.isArray(history[key]) ? history[key] : [];
|
||||||
|
return sum + items.length;
|
||||||
|
}, 0);
|
||||||
|
self.postMessage({ type: "start", total });
|
||||||
|
|
||||||
|
let processed = 0;
|
||||||
|
const safeLimit = Math.max(1, Math.min(2048, Number(batchLimit) || 512));
|
||||||
|
|
||||||
|
for (const kind of HISTORY_GROUP_KEYS) {
|
||||||
|
const items = history && Array.isArray(history[kind]) ? history[kind] : [];
|
||||||
|
if (items.length === 0) continue;
|
||||||
|
for (let index = 0; index < items.length; index += safeLimit) {
|
||||||
|
const messages = items.slice(index, index + safeLimit);
|
||||||
|
processed += messages.length;
|
||||||
|
self.postMessage({
|
||||||
|
type: "group",
|
||||||
|
kind,
|
||||||
|
messages,
|
||||||
|
processed,
|
||||||
|
total,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.postMessage({ type: "done", total });
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event) => {
|
||||||
|
const data = event?.data || {};
|
||||||
|
if (data?.type !== "fetch-history") return;
|
||||||
|
fetchAndDecodeHistory(data.url || "/decode/history", data.batchLimit)
|
||||||
|
.catch((err) => {
|
||||||
|
self.postMessage({
|
||||||
|
type: "error",
|
||||||
|
message: err && err.message ? err.message : String(err || "unknown worker failure"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
+120
@@ -0,0 +1,120 @@
|
|||||||
|
(function() {
|
||||||
|
if (typeof L === "undefined") return;
|
||||||
|
|
||||||
|
function clamp(value, min, max) {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteAngle(value) {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
const normalized = ((Number(value) % 360) + 360) % 360;
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function svgColor(value, fallback) {
|
||||||
|
const text = String(value || fallback || "");
|
||||||
|
return text.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSymbolHtml(options, zoom) {
|
||||||
|
const heading = finiteAngle(options.heading);
|
||||||
|
const course = finiteAngle(options.course);
|
||||||
|
const angle = heading != null ? heading : course;
|
||||||
|
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||||||
|
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
||||||
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
|
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||||
|
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
||||||
|
const color = svgColor(options.color, "#ff7559");
|
||||||
|
const outline = svgColor(options.outline, "#6b2118");
|
||||||
|
|
||||||
|
const body = angle != null
|
||||||
|
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
||||||
|
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
||||||
|
`</g>`
|
||||||
|
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||||
|
|
||||||
|
const courseLine = course != null
|
||||||
|
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
||||||
|
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
||||||
|
`</g>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
||||||
|
courseLine +
|
||||||
|
body +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
L.TrxAisTrackSymbol = L.Marker.extend({
|
||||||
|
options: {
|
||||||
|
heading: null,
|
||||||
|
course: null,
|
||||||
|
speed: null,
|
||||||
|
color: "#ff7559",
|
||||||
|
outline: "#6b2118",
|
||||||
|
size: 22,
|
||||||
|
interactive: true,
|
||||||
|
keyboard: true,
|
||||||
|
riseOnHover: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
initialize: function(latlng, options) {
|
||||||
|
const merged = L.Util.extend({}, this.options, options || {});
|
||||||
|
merged.icon = L.divIcon({
|
||||||
|
className: "trx-ais-track-symbol-icon",
|
||||||
|
html: "",
|
||||||
|
iconSize: [merged.size, merged.size],
|
||||||
|
iconAnchor: [merged.size / 2, merged.size / 2],
|
||||||
|
});
|
||||||
|
L.Marker.prototype.initialize.call(this, latlng, merged);
|
||||||
|
},
|
||||||
|
|
||||||
|
onAdd: function(map) {
|
||||||
|
L.Marker.prototype.onAdd.call(this, map);
|
||||||
|
this._refreshIcon();
|
||||||
|
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||||||
|
map.on("zoomend", this._boundZoomRefresh);
|
||||||
|
},
|
||||||
|
|
||||||
|
onRemove: function(map) {
|
||||||
|
if (this._boundZoomRefresh) {
|
||||||
|
map.off("zoomend", this._boundZoomRefresh);
|
||||||
|
this._boundZoomRefresh = null;
|
||||||
|
}
|
||||||
|
L.Marker.prototype.onRemove.call(this, map);
|
||||||
|
},
|
||||||
|
|
||||||
|
setAisState: function(next) {
|
||||||
|
if (next && typeof next === "object") {
|
||||||
|
if ("heading" in next) this.options.heading = next.heading;
|
||||||
|
if ("course" in next) this.options.course = next.course;
|
||||||
|
if ("speed" in next) this.options.speed = next.speed;
|
||||||
|
if ("color" in next) this.options.color = next.color;
|
||||||
|
if ("outline" in next) this.options.outline = next.outline;
|
||||||
|
}
|
||||||
|
this._refreshIcon();
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
_refreshIcon: function() {
|
||||||
|
if (!this._icon) return;
|
||||||
|
const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
|
||||||
|
const html = buildSymbolHtml(this.options, zoom);
|
||||||
|
this._icon.innerHTML = html;
|
||||||
|
const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
|
||||||
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
|
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||||
|
this._icon.style.width = `${size}px`;
|
||||||
|
this._icon.style.height = `${size}px`;
|
||||||
|
this._icon.style.marginLeft = `${-size / 2}px`;
|
||||||
|
this._icon.style.marginTop = `${-size / 2}px`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
L.trxAisTrackSymbol = function(latlng, options) {
|
||||||
|
return new L.TrxAisTrackSymbol(latlng, options);
|
||||||
|
};
|
||||||
|
})();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,407 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- AIS Decoder Plugin (server-side decode) ---
|
||||||
|
const aisStatus = document.getElementById("ais-status");
|
||||||
|
const aisMessagesEl = document.getElementById("ais-messages");
|
||||||
|
const aisFilterInput = document.getElementById("ais-filter");
|
||||||
|
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
|
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
|
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
|
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||||
|
const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const AIS_DEFAULT_A_HZ = 161_975_000;
|
||||||
|
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||||
|
let aisFilterText = "";
|
||||||
|
let aisMessageHistory = [];
|
||||||
|
|
||||||
|
function currentAisHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneAisMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
|
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAisUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAisHistoryRender() {
|
||||||
|
scheduleAisUi("ais-history", () => renderAisHistory());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAisBarUpdate() {
|
||||||
|
scheduleAisUi("ais-bar", () => updateAisBar());
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAisMhz(freqHz) {
|
||||||
|
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentAisChannelPlan() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
|
return {
|
||||||
|
aHz: safeAHz,
|
||||||
|
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisChannelInfo(channel) {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
const ch = String(channel || "").trim().toUpperCase();
|
||||||
|
if (ch === "B") {
|
||||||
|
return {
|
||||||
|
label: "AIS-B",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-b",
|
||||||
|
freqText: formatAisMhz(plan.bHz),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
label: "AIS-A",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-a",
|
||||||
|
freqText: formatAisMhz(plan.aHz),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisDisplayName(msg) {
|
||||||
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisDisplayNameHtml(msg) {
|
||||||
|
const label = escapeMapHtml(aisDisplayName(msg));
|
||||||
|
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||||
|
if (!url) return label;
|
||||||
|
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisTypeLabel(type) {
|
||||||
|
switch (Number(type)) {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
case 3:
|
||||||
|
return "Class A Position";
|
||||||
|
case 4:
|
||||||
|
return "Base Station";
|
||||||
|
case 5:
|
||||||
|
return "Static/Voyage";
|
||||||
|
case 18:
|
||||||
|
return "Class B Position";
|
||||||
|
case 19:
|
||||||
|
return "Class B Extended";
|
||||||
|
case 21:
|
||||||
|
return "Aid to Nav";
|
||||||
|
case 24:
|
||||||
|
return "Class B Static";
|
||||||
|
default:
|
||||||
|
return `Type ${type ?? "--"}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisMotionText(msg) {
|
||||||
|
const parts = [
|
||||||
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
||||||
|
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisRouteText(msg) {
|
||||||
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisDistanceText(msg) {
|
||||||
|
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aisLatestByVessel(messages) {
|
||||||
|
const byMmsi = new Map();
|
||||||
|
for (const msg of messages) {
|
||||||
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
|
}
|
||||||
|
return Array.from(byMmsi.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAisSummary() {
|
||||||
|
const plan = currentAisChannelPlan();
|
||||||
|
if (aisChannelSummaryEl) {
|
||||||
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const vessels = aisLatestByVessel(aisMessageHistory);
|
||||||
|
if (aisVesselCountEl) {
|
||||||
|
const count = vessels.length;
|
||||||
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aisLatestSeenEl) {
|
||||||
|
const latest = aisMessageHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
aisLatestSeenEl.textContent = "No traffic yet";
|
||||||
|
} else {
|
||||||
|
const channel = aisChannelInfo(latest.channel);
|
||||||
|
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAisRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ais-message";
|
||||||
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
const name = aisDisplayName(msg);
|
||||||
|
const nameHtml = aisDisplayNameHtml(msg);
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const motion = aisMotionText(msg);
|
||||||
|
const route = aisRouteText(msg);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const pos = msg.lat != null && msg.lon != null
|
||||||
|
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||||
|
: "";
|
||||||
|
row.dataset.filterText = [
|
||||||
|
name,
|
||||||
|
msg.mmsi,
|
||||||
|
msg.channel,
|
||||||
|
channel.label,
|
||||||
|
msg.vessel_name,
|
||||||
|
msg.callsign,
|
||||||
|
msg.destination,
|
||||||
|
aisTypeLabel(msg.message_type),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
row.innerHTML =
|
||||||
|
`<div class="ais-row-head">` +
|
||||||
|
`<span class="ais-time">${ts}</span>` +
|
||||||
|
`<span class="ais-call">${nameHtml}</span>` +
|
||||||
|
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
||||||
|
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="ais-row-meta">` +
|
||||||
|
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
||||||
|
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
||||||
|
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="ais-row-detail">` +
|
||||||
|
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||||
|
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
||||||
|
(pos ? `<span>${pos}</span>` : "") +
|
||||||
|
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||||
|
`</div>`;
|
||||||
|
applyAisFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAisFilterToRow(row) {
|
||||||
|
if (!aisFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.filterText || "";
|
||||||
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAisFilterToAll() {
|
||||||
|
if (!aisMessagesEl) return;
|
||||||
|
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
||||||
|
rows.forEach((row) => applyAisFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAisBar() {
|
||||||
|
if (!aisBarOverlay) return;
|
||||||
|
updateAisSummary();
|
||||||
|
|
||||||
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
|
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
||||||
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
|
if (!isAis || messages.length === 0) {
|
||||||
|
aisBarOverlay.style.display = "none";
|
||||||
|
aisBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const pin = msg.lat != null && msg.lon != null
|
||||||
|
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
|
||||||
|
: "";
|
||||||
|
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||||
|
const channel = aisChannelInfo(msg.channel);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
|
const details = [
|
||||||
|
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||||
|
escapeMapHtml(channel.label),
|
||||||
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||||
|
distance ? escapeMapHtml(distance) : null,
|
||||||
|
escapeMapHtml(aisAgeText(msg._tsMs)),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame">` +
|
||||||
|
`<div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div>` +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
aisBarOverlay.innerHTML = html;
|
||||||
|
aisBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateAisBar = updateAisBar;
|
||||||
|
window.clearAisBar = function() {
|
||||||
|
window.resetAisHistoryView();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetAisHistoryView = function() {
|
||||||
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
|
aisMessageHistory = [];
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderAisHistory() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
if (!aisMessagesEl) {
|
||||||
|
updateAisSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
||||||
|
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
||||||
|
}
|
||||||
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
|
updateAisSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAisMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
aisMessageHistory.unshift(msg);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
|
||||||
|
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
||||||
|
window.aisMapAddVessel(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerAisMessage(msg) {
|
||||||
|
return {
|
||||||
|
rig_id: msg.rig_id || null,
|
||||||
|
channel: msg.channel,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
mmsi: msg.mmsi,
|
||||||
|
lat: msg.lat,
|
||||||
|
lon: msg.lon,
|
||||||
|
sog_knots: msg.sog_knots,
|
||||||
|
cog_deg: msg.cog_deg,
|
||||||
|
heading_deg: msg.heading_deg,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerAisBatch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerAisMessage(msg);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
||||||
|
window.aisMapAddVessel(next);
|
||||||
|
}
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
scheduleAisBarUpdate();
|
||||||
|
scheduleAisHistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreAisHistory = function(messages) {
|
||||||
|
window.onServerAisBatch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneAisHistoryView = function() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ais_decode");
|
||||||
|
window.resetAisHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("AIS history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aisFilterInput) {
|
||||||
|
aisFilterInput.addEventListener("input", () => {
|
||||||
|
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerAis = function(msg) {
|
||||||
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
|
};
|
||||||
|
|
||||||
|
updateAisSummary();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- APRS Decoder Plugin (server-side decode) ---
|
||||||
|
const aprsStatus = document.getElementById("aprs-status");
|
||||||
|
const aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
|
const aprsFilterInput = document.getElementById("aprs-filter");
|
||||||
|
const aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
|
const aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
|
const aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
|
const aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||||
|
const aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||||
|
const aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||||
|
const aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
|
const APRS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
let aprsFilterText = "";
|
||||||
|
let aprsPacketHistory = [];
|
||||||
|
let aprsBarDismissedAtMs = 0;
|
||||||
|
let aprsOnlyPos = false;
|
||||||
|
let aprsHideCrc = false;
|
||||||
|
let aprsCollapseDup = false;
|
||||||
|
let aprsTypeFilter = "all";
|
||||||
|
|
||||||
|
function currentAprsHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAprsUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAprsHistoryRender() {
|
||||||
|
scheduleAprsUi("aprs-history", () => renderAprsHistory());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleAprsBarUpdate() {
|
||||||
|
scheduleAprsUi("aprs-bar", () => updateAprsBar());
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAprsInfo(pkt) {
|
||||||
|
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||||
|
if (bytes && bytes.length > 0) {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i];
|
||||||
|
if (b >= 0x20 && b <= 0x7e) {
|
||||||
|
const ch = String.fromCharCode(b);
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const str = pkt.info || "";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code >= 0x20 && code <= 0x7e) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsPacketCategory(pkt) {
|
||||||
|
const type = String(pkt.type || "").toLowerCase();
|
||||||
|
const info = String(pkt.info || "").toLowerCase();
|
||||||
|
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position": return "Position";
|
||||||
|
case "message": return "Message";
|
||||||
|
case "weather": return "Weather";
|
||||||
|
case "telemetry": return "Telemetry";
|
||||||
|
default: return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsDistanceText(pkt) {
|
||||||
|
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsPacketSignature(pkt) {
|
||||||
|
return [
|
||||||
|
pkt.srcCall || "",
|
||||||
|
pkt.destCall || "",
|
||||||
|
pkt.path || "",
|
||||||
|
pkt.info || "",
|
||||||
|
pkt.type || "",
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||||
|
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsFilterMatch(pkt) {
|
||||||
|
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (aprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
||||||
|
if (!aprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
aprsPacketCategory(pkt),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
return haystack.includes(aprsFilterText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aprsVisiblePackets() {
|
||||||
|
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
||||||
|
return packets.filter(aprsFilterMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const key = aprsPacketSignature(pkt);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(pkt);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAprsSummary() {
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
if (aprsTotalCountEl) {
|
||||||
|
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||||
|
}
|
||||||
|
if (aprsVisibleCountEl) {
|
||||||
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
|
}
|
||||||
|
if (aprsLatestSeenEl) {
|
||||||
|
const latest = aprsPacketHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
||||||
|
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||||
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAprsRow(pkt, isFresh) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
|
|
||||||
|
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const age = aprsAgeText(pkt._tsMs);
|
||||||
|
const category = aprsPacketCategory(pkt);
|
||||||
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||||
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
|
const posLink = pkt.lat != null && pkt.lon != null
|
||||||
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||||
|
: "";
|
||||||
|
const distance = aprsDistanceText(pkt);
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
`<div class="aprs-row-head">` +
|
||||||
|
`<span class="aprs-time">${ts}</span>` +
|
||||||
|
symbolHtml +
|
||||||
|
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||||
|
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||||
|
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||||
|
pathBadge +
|
||||||
|
crcBadge +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-meta">` +
|
||||||
|
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||||
|
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||||
|
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-detail">` +
|
||||||
|
`<span title="${escapeMapHtml(pkt.type || "")}">${renderAprsInfo(pkt)}</span>` +
|
||||||
|
(posLink ? `<span>${posLink}</span>` : "") +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-actions">` +
|
||||||
|
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
||||||
|
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
||||||
|
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||||
|
`</div>` +
|
||||||
|
`<details class="aprs-details">` +
|
||||||
|
`<summary>Details</summary>` +
|
||||||
|
`<div class="aprs-details-grid">` +
|
||||||
|
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||||
|
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(aprsHexBytes(pkt.info_bytes))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`</details>`;
|
||||||
|
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = String(el.dataset.aprsMap || "");
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
window.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(raw);
|
||||||
|
showHint("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
showHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAprsHistory() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (!aprsPacketsEl) {
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < visible.length; i++) {
|
||||||
|
fragment.appendChild(renderAprsRow(visible[i], i === 0));
|
||||||
|
}
|
||||||
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateAprsSummary();
|
||||||
|
updateAprsChipState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAprsBar() {
|
||||||
|
if (!aprsBarOverlay) return;
|
||||||
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && p._tsMs >= cutoffMs);
|
||||||
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">APRS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAprsBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearAprsBar();}" aria-label="Clear APRS overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeAprsBar()" aria-label="Close APRS overlay">×</button></span></div>';
|
||||||
|
for (const pkt of frames) {
|
||||||
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
|
const call = `<span class="aprs-bar-call">${escapeMapHtml(pkt.srcCall)}</span>`;
|
||||||
|
const dest = escapeMapHtml(pkt.destCall || "");
|
||||||
|
const info = escapeMapHtml(pkt.info || "");
|
||||||
|
const pin = pkt.lat != null && pkt.lon != null
|
||||||
|
? `<button class="aprs-bar-pin" title="${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${pkt.lat},${pkt.lon})">📍</button>`
|
||||||
|
: "";
|
||||||
|
html += `<div class="aprs-bar-frame">` +
|
||||||
|
`<div class="aprs-bar-frame-main">${ts}${pin}${call}>${dest}: ${info}</div>` +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
aprsBarOverlay.innerHTML = html;
|
||||||
|
aprsBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateAprsBar = updateAprsBar;
|
||||||
|
window.clearAprsBar = function() {
|
||||||
|
window.resetAprsHistoryView();
|
||||||
|
};
|
||||||
|
window.closeAprsBar = function() {
|
||||||
|
aprsBarDismissedAtMs = Date.now();
|
||||||
|
if (aprsBarOverlay) {
|
||||||
|
aprsBarOverlay.style.display = "none";
|
||||||
|
aprsBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetAprsHistoryView = function() {
|
||||||
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
|
aprsPacketHistory = [];
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("aprs");
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneAprsHistoryView = function() {
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
updateAprsBar();
|
||||||
|
renderAprsHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
function addAprsPacket(pkt) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
|
||||||
|
aprsPacketHistory.unshift(pkt);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
|
||||||
|
if (pkt.lat != null && pkt.lon != null && window.aprsMapAddStation) {
|
||||||
|
window.aprsMapAddStation(pkt.srcCall, pkt.lat, pkt.lon, pkt.info, pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerAprsPacket(pkt) {
|
||||||
|
return {
|
||||||
|
rig_id: pkt.rig_id || null,
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
srcCall: pkt.src_call,
|
||||||
|
destCall: pkt.dest_call,
|
||||||
|
path: pkt.path,
|
||||||
|
info: pkt.info,
|
||||||
|
info_bytes: pkt.info_bytes,
|
||||||
|
type: pkt.packet_type,
|
||||||
|
crcOk: pkt.crc_ok,
|
||||||
|
ts_ms: pkt.ts_ms,
|
||||||
|
lat: pkt.lat,
|
||||||
|
lon: pkt.lon,
|
||||||
|
symbolTable: pkt.symbol_table,
|
||||||
|
symbolCode: pkt.symbol_code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerAprsBatch = function(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
aprsStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
let hasCrcOk = false;
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const next = normalizeServerAprsPacket(pkt);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
if (next.lat != null && next.lon != null && window.aprsMapAddStation) {
|
||||||
|
window.aprsMapAddStation(next.srcCall, next.lat, next.lon, next.info, next.symbolTable, next.symbolCode, next);
|
||||||
|
}
|
||||||
|
if (next.crcOk) hasCrcOk = true;
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
aprsPacketHistory = normalized.concat(aprsPacketHistory);
|
||||||
|
pruneAprsPacketHistory();
|
||||||
|
if (hasCrcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreAprsHistory = function(packets) {
|
||||||
|
window.onServerAprsBatch(packets);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_aprs_decode");
|
||||||
|
window.resetAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aprsOnlyPosBtn) {
|
||||||
|
aprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
aprsOnlyPos = !aprsOnlyPos;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aprsHideCrcBtn) {
|
||||||
|
aprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
aprsHideCrc = !aprsHideCrc;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (aprsCollapseDupBtn) {
|
||||||
|
aprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
aprsCollapseDup = !aprsCollapseDup;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
aprsTypeFilter = type;
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (aprsFilterInput) {
|
||||||
|
aprsFilterInput.addEventListener("input", () => {
|
||||||
|
aprsFilterText = aprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server-side APRS decode handler ---
|
||||||
|
window.onServerAprs = function(pkt) {
|
||||||
|
aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
|
};
|
||||||
|
|
||||||
|
renderAprsHistory();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("aprs");
|
||||||
+410
@@ -0,0 +1,410 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
function bgdSupportedIds() {
|
||||||
|
return (window.decoderRegistry || [])
|
||||||
|
.filter(function (d) { return d.background_decode; })
|
||||||
|
.map(function (d) { return d.id; });
|
||||||
|
}
|
||||||
|
|
||||||
|
let backgroundDecodeRole = null;
|
||||||
|
let currentRigId = null;
|
||||||
|
let currentConfig = null;
|
||||||
|
let bookmarkList = [];
|
||||||
|
let statusInterval = null;
|
||||||
|
let bgdDirty = false;
|
||||||
|
|
||||||
|
function initBackgroundDecode(rigId, role) {
|
||||||
|
backgroundDecodeRole = role;
|
||||||
|
currentRigId = rigId || null;
|
||||||
|
if (currentRigId) loadBackgroundDecode();
|
||||||
|
startStatusPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBackgroundDecodeRig(rigId) {
|
||||||
|
const nextRigId = rigId || null;
|
||||||
|
if (nextRigId === currentRigId) return;
|
||||||
|
currentRigId = nextRigId;
|
||||||
|
if (!currentRigId) return;
|
||||||
|
loadBackgroundDecode();
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiGetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiPutConfig(rigId, config) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiResetConfig(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
||||||
|
method: "DELETE",
|
||||||
|
}).then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiGetStatus(rigId) {
|
||||||
|
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiGetBookmarks() {
|
||||||
|
return fetch("/bookmarks").then(function (r) {
|
||||||
|
if (!r.ok) throw new Error("HTTP " + r.status);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()])
|
||||||
|
.then(function ([config, bookmarks]) {
|
||||||
|
currentConfig = config || { remote: rigId, enabled: false, bookmark_ids: [] };
|
||||||
|
bookmarkList = Array.isArray(bookmarks) ? bookmarks : [];
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
console.error("background decode load failed", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function supportedBookmarks() {
|
||||||
|
return bookmarkList.filter(function (bookmark) {
|
||||||
|
return bookmarkDecoderKinds(bookmark).length > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bookmarkDecoderKinds(bookmark) {
|
||||||
|
var ids = bgdSupportedIds();
|
||||||
|
var decoders = Array.isArray(bookmark && bookmark.decoders) ? bookmark.decoders : [];
|
||||||
|
var explicit = decoders
|
||||||
|
.map(function (item) { return String(item || "").trim().toLowerCase(); })
|
||||||
|
.filter(function (item, index, arr) {
|
||||||
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
|
});
|
||||||
|
if (explicit.length > 0) return explicit;
|
||||||
|
// Fall back: infer from mode via mode-bound entries in the registry.
|
||||||
|
var mode = String(bookmark && bookmark.mode || "").trim().toUpperCase();
|
||||||
|
return (window.decoderRegistry || [])
|
||||||
|
.filter(function (d) {
|
||||||
|
return d.activation === "mode_bound" && d.background_decode
|
||||||
|
&& d.active_modes.indexOf(mode) >= 0;
|
||||||
|
})
|
||||||
|
.map(function (d) { return d.id; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBackgroundDecode() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
setCheckbox("background-decode-enabled", !!currentConfig.enabled);
|
||||||
|
renderBookmarkChecklist();
|
||||||
|
|
||||||
|
const isControl = backgroundDecodeRole === "control" || (typeof authEnabled !== "undefined" && !authEnabled);
|
||||||
|
const panel = document.getElementById("background-decode-panel");
|
||||||
|
if (panel) {
|
||||||
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function (el) {
|
||||||
|
el.disabled = !isControl;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBookmarkChecklist(filterText) {
|
||||||
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
|
||||||
|
const selectedIds = new Set(
|
||||||
|
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
||||||
|
);
|
||||||
|
const all = supportedBookmarks();
|
||||||
|
const filter = (filterText || "").trim().toLowerCase();
|
||||||
|
|
||||||
|
const filtered = filter
|
||||||
|
? all.filter(function (bm) {
|
||||||
|
var text = (bm.name + " " + formatFreq(bm.freq_hz) + " " + bm.mode).toLowerCase();
|
||||||
|
return text.indexOf(filter) >= 0;
|
||||||
|
})
|
||||||
|
: all;
|
||||||
|
|
||||||
|
if (filtered.length === 0) {
|
||||||
|
container.innerHTML = '<div class="bgd-checklist-empty">' +
|
||||||
|
(all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") +
|
||||||
|
'</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered.forEach(function (bookmark) {
|
||||||
|
var row = document.createElement("label");
|
||||||
|
row.className = "bgd-checklist-row";
|
||||||
|
var decoders = bookmarkDecoderKinds(bookmark);
|
||||||
|
var checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
|
row.innerHTML =
|
||||||
|
'<input type="checkbox"' + checked + ' data-bm-id="' + escHtml(bookmark.id) + '" />' +
|
||||||
|
'<span class="bgd-checklist-name">' + escHtml(bookmark.name) + '</span>' +
|
||||||
|
'<span class="bgd-checklist-meta">' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + '</span>';
|
||||||
|
row.querySelector("input").addEventListener("change", function (e) {
|
||||||
|
onChecklistToggle(bookmark.id, e.target.checked);
|
||||||
|
});
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
||||||
|
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
||||||
|
currentConfig.bookmark_ids.push(bookmarkId);
|
||||||
|
} else if (!checked) {
|
||||||
|
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function (id) { return id !== bookmarkId; });
|
||||||
|
}
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
const payload = {
|
||||||
|
remote: rigId,
|
||||||
|
enabled: !!document.getElementById("background-decode-enabled").checked,
|
||||||
|
bookmark_ids: Array.isArray(currentConfig && currentConfig.bookmark_ids) ? currentConfig.bookmark_ids.slice() : [],
|
||||||
|
};
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
apiPutConfig(rigId, payload)
|
||||||
|
.then(function (saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode saved.");
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showToast("Save failed: " + err.message, true);
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
if (!await window.trxUi.confirm({ title: "Reset background decoding?", message: "The current background decode configuration will be permanently reset.", confirmLabel: "Reset" })) return;
|
||||||
|
apiResetConfig(rigId)
|
||||||
|
.then(function (saved) {
|
||||||
|
currentConfig = saved;
|
||||||
|
renderBackgroundDecode();
|
||||||
|
clearBgdDirty();
|
||||||
|
pollBackgroundDecodeStatus();
|
||||||
|
showToast("Background decode reset.");
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
showToast("Reset failed: " + err.message, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStatusPolling() {
|
||||||
|
if (statusInterval) clearInterval(statusInterval);
|
||||||
|
statusInterval = setInterval(pollBackgroundDecodeStatus, 15000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollBackgroundDecodeStatus() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
apiGetStatus(rigId)
|
||||||
|
.then(renderStatus)
|
||||||
|
.catch(function () {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(status) {
|
||||||
|
const card = document.getElementById("background-decode-status-card");
|
||||||
|
if (!card) return;
|
||||||
|
const entries = Array.isArray(status && status.entries) ? status.entries : [];
|
||||||
|
if (!entries.length) {
|
||||||
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
if (status.active_rig) {
|
||||||
|
if (Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||||
|
if (Number.isFinite(status.sample_rate) && status.sample_rate > 0) summary.push("Span ±" + formatFreq(status.sample_rate / 2));
|
||||||
|
} else {
|
||||||
|
summary.push("This rig is not currently selected for audio.");
|
||||||
|
}
|
||||||
|
let html = summary.length ? '<div style="margin-bottom:0.8rem;color:var(--text-muted);">' + escHtml(summary.join(" · ")) + "</div>" : "";
|
||||||
|
html += '<div class="bgd-status-list">';
|
||||||
|
entries.forEach(function (entry) {
|
||||||
|
const name = entry.bookmark_name || entry.bookmark_id || "Unknown bookmark";
|
||||||
|
const parts = [];
|
||||||
|
if (Number.isFinite(entry.freq_hz)) parts.push(formatFreq(entry.freq_hz));
|
||||||
|
if (entry.mode) parts.push(entry.mode);
|
||||||
|
if (Array.isArray(entry.decoder_kinds) && entry.decoder_kinds.length) {
|
||||||
|
parts.push(entry.decoder_kinds.join("/").toUpperCase());
|
||||||
|
}
|
||||||
|
html +=
|
||||||
|
'<div class="bgd-status-row">' +
|
||||||
|
'<div>' +
|
||||||
|
'<div class="bgd-status-name">' + escHtml(name) + '</div>' +
|
||||||
|
'<div class="bgd-status-meta">' + escHtml(parts.join(" · ")) + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="bgd-status-state" data-state="' + escHtml(entry.state || "inactive") + '">' +
|
||||||
|
'<svg class="bgd-state-dot" viewBox="0 0 8 8"><circle cx="4" cy="4" r="3.5"/></svg>' +
|
||||||
|
escHtml(prettyState(entry.state)) + '</div>' +
|
||||||
|
'</div>';
|
||||||
|
});
|
||||||
|
html += "</div>";
|
||||||
|
card.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function prettyState(state) {
|
||||||
|
switch (state) {
|
||||||
|
case "active": return "\u2713 Active";
|
||||||
|
case "out_of_span": return "\u25B3 Out of span";
|
||||||
|
case "waiting_for_spectrum": return "\u25B3 Waiting";
|
||||||
|
case "waiting_for_user": return "\u25B3 No user";
|
||||||
|
case "missing_bookmark": return "\u2717 Missing";
|
||||||
|
case "no_supported_decoders": return "\u2717 Unsupported";
|
||||||
|
case "disabled": return "\u25B3 Disabled";
|
||||||
|
case "handled_by_scheduler": return "\u25B3 Scheduler";
|
||||||
|
case "scheduler_has_control": return "\u25B3 Scheduler";
|
||||||
|
case "handled_by_virtual_channel": return "\u25B3 VChan";
|
||||||
|
default: return "\u25B3 Inactive";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCheckbox(id, value) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.checked = !!value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3).replace(/\.?0+$/, "") + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
|
return hz + " Hz";
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(value) {
|
||||||
|
return String(value == null ? "" : value)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function markBgdDirty() {
|
||||||
|
if (bgdDirty) return;
|
||||||
|
bgdDirty = true;
|
||||||
|
var btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBgdDirty() {
|
||||||
|
bgdDirty = false;
|
||||||
|
var btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.remove("sch-dirty");
|
||||||
|
}
|
||||||
|
|
||||||
|
function showToast(msg, isError) {
|
||||||
|
const el = document.getElementById("background-decode-toast");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg;
|
||||||
|
el.style.background = isError ? "var(--color-error, #c00)" : "var(--accent-green)";
|
||||||
|
el.style.display = "block";
|
||||||
|
setTimeout(function () {
|
||||||
|
el.style.display = "none";
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
var ids = supportedBookmarks().map(function (bm) { return bm.id; });
|
||||||
|
currentConfig.bookmark_ids = ids;
|
||||||
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deselectAllBookmarks() {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
|
}
|
||||||
|
currentConfig.bookmark_ids = [];
|
||||||
|
renderBookmarkChecklist(document.getElementById("bgd-bookmark-filter")?.value);
|
||||||
|
markBgdDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireBackgroundDecodeEvents() {
|
||||||
|
const filterInput = document.getElementById("bgd-bookmark-filter");
|
||||||
|
if (filterInput && !filterInput._wired) {
|
||||||
|
filterInput._wired = true;
|
||||||
|
filterInput.addEventListener("input", function () {
|
||||||
|
renderBookmarkChecklist(filterInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabledCb = document.getElementById("background-decode-enabled");
|
||||||
|
if (enabledCb && !enabledCb._wired) {
|
||||||
|
enabledCb._wired = true;
|
||||||
|
enabledCb.addEventListener("change", function () { markBgdDirty(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
|
if (selectAllBtn && !selectAllBtn._wired) {
|
||||||
|
selectAllBtn._wired = true;
|
||||||
|
selectAllBtn.addEventListener("click", selectAllBookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const deselectAllBtn = document.getElementById("bgd-deselect-all-btn");
|
||||||
|
if (deselectAllBtn && !deselectAllBtn._wired) {
|
||||||
|
deselectAllBtn._wired = true;
|
||||||
|
deselectAllBtn.addEventListener("click", deselectAllBookmarks);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (saveBtn && !saveBtn._wired) {
|
||||||
|
saveBtn._wired = true;
|
||||||
|
saveBtn.addEventListener("click", saveBackgroundDecode);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
|
if (resetBtn && !resetBtn._wired) {
|
||||||
|
resetBtn._wired = true;
|
||||||
|
resetBtn.addEventListener("click", resetBackgroundDecode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initBackgroundDecode = initBackgroundDecode;
|
||||||
|
window.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||||
|
window.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||||
|
})();
|
||||||
@@ -0,0 +1,807 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- Bookmarks Tab ---
|
||||||
|
|
||||||
|
/** Current bookmark scope: "general" or a rig remote name. */
|
||||||
|
let bmScope = "general";
|
||||||
|
|
||||||
|
/** Build the ?scope= query string for a given or current bookmark scope. */
|
||||||
|
function bmScopeParam(prefix, scope) {
|
||||||
|
const sep = prefix ? "&" : "?";
|
||||||
|
return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope);
|
||||||
|
}
|
||||||
|
|
||||||
|
var bmList = [];
|
||||||
|
var bmRevision = 0;
|
||||||
|
/** Overlay list: always merged general + active rig bookmarks (for spectrum/map). */
|
||||||
|
var bmOverlayList = [];
|
||||||
|
var bmOverlayRevision = 0;
|
||||||
|
let bmFilteredList = [];
|
||||||
|
let bmEditId = null;
|
||||||
|
let bmEditScope = null;
|
||||||
|
let bmCurrentPage = 1;
|
||||||
|
const BM_PAGE_SIZE = 25;
|
||||||
|
const bmSelected = new Set();
|
||||||
|
|
||||||
|
function bmFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||||
|
return hz + "\u202fHz";
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmEsc(str) {
|
||||||
|
const d = document.createElement("div");
|
||||||
|
d.appendChild(document.createTextNode(String(str)));
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmCanControl() {
|
||||||
|
return (
|
||||||
|
(typeof authEnabled !== "undefined" && !authEnabled) ||
|
||||||
|
(typeof authRole !== "undefined" && authRole === "control")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show/hide the Add Bookmark / Select All buttons based on the current auth role.
|
||||||
|
function bmSyncAccess() {
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const addBtn = document.getElementById("bm-add-btn");
|
||||||
|
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||||
|
if (addBtn) addBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The listing scope: always the active rig (to merge general + rig bookmarks). */
|
||||||
|
function bmListScope() {
|
||||||
|
const rig = (typeof lastActiveRigId !== "undefined") ? lastActiveRigId : null;
|
||||||
|
return rig || "general";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmFetchOverlay() {
|
||||||
|
const overlayScope = bmListScope();
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope));
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
bmOverlayList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch overlay bookmarks:", e);
|
||||||
|
bmOverlayList = [];
|
||||||
|
}
|
||||||
|
bmOverlayRevision++;
|
||||||
|
if (typeof window.syncBookmarkMapLocators === "function") {
|
||||||
|
window.syncBookmarkMapLocators(bmOverlayList);
|
||||||
|
}
|
||||||
|
if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmFetch(categoryFilter) {
|
||||||
|
let url = "/bookmarks";
|
||||||
|
let hasQuery = false;
|
||||||
|
if (categoryFilter && categoryFilter !== "") {
|
||||||
|
url += "?category=" + encodeURIComponent(categoryFilter);
|
||||||
|
hasQuery = true;
|
||||||
|
}
|
||||||
|
url += bmScopeParam(hasQuery);
|
||||||
|
const overlayPromise = bmFetchOverlay();
|
||||||
|
try {
|
||||||
|
const resp = await fetch(url);
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
bmList = await resp.json();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to fetch bookmarks:", e);
|
||||||
|
bmList = [];
|
||||||
|
}
|
||||||
|
bmRevision++;
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
bmSyncAccess();
|
||||||
|
bmApplyFilters();
|
||||||
|
bmRefreshCategoryFilter(categoryFilter);
|
||||||
|
await overlayPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmApplyFilters() {
|
||||||
|
const text = (document.getElementById("bm-text-filter")?.value || "").trim().toLowerCase();
|
||||||
|
const modeFilter = (document.getElementById("bm-mode-filter")?.value || "").trim().toUpperCase();
|
||||||
|
let filtered = modeFilter
|
||||||
|
? bmList.filter((bm) => String(bm.mode || "").toUpperCase() === modeFilter)
|
||||||
|
: bmList;
|
||||||
|
filtered = text
|
||||||
|
? filtered.filter((bm) =>
|
||||||
|
(bm.name || "").toLowerCase().includes(text) ||
|
||||||
|
(bm.locator || "").toLowerCase().includes(text) ||
|
||||||
|
(bm.category || "").toLowerCase().includes(text) ||
|
||||||
|
(bm.comment || "").toLowerCase().includes(text)
|
||||||
|
)
|
||||||
|
: filtered;
|
||||||
|
bmFilteredList = filtered;
|
||||||
|
bmCurrentPage = 1;
|
||||||
|
bmRender(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmRefreshCategoryFilter(keepValue) {
|
||||||
|
const sel = document.getElementById("bm-category-filter");
|
||||||
|
const modeSel = document.getElementById("bm-mode-filter");
|
||||||
|
if (!sel && !modeSel) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks" + bmScopeParam(false));
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const all = await resp.json();
|
||||||
|
if (sel) {
|
||||||
|
const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort();
|
||||||
|
while (sel.options.length > 1) sel.remove(1);
|
||||||
|
cats.forEach((cat) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = cat;
|
||||||
|
opt.textContent = cat;
|
||||||
|
sel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepValue && cats.includes(keepValue)) sel.value = keepValue;
|
||||||
|
}
|
||||||
|
if (modeSel) {
|
||||||
|
const keepMode = modeSel.value;
|
||||||
|
const modes = [...new Set(all.map((b) => String(b.mode || "").trim().toUpperCase()).filter(Boolean))].sort();
|
||||||
|
while (modeSel.options.length > 1) modeSel.remove(1);
|
||||||
|
modes.forEach((mode) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = mode;
|
||||||
|
opt.textContent = mode;
|
||||||
|
modeSel.add(opt);
|
||||||
|
});
|
||||||
|
if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmRender(list) {
|
||||||
|
const tbody = document.getElementById("bm-tbody");
|
||||||
|
const emptyEl = document.getElementById("bm-empty");
|
||||||
|
const paginatorEl = document.getElementById("bm-paginator");
|
||||||
|
const pageSummaryEl = document.getElementById("bm-page-summary");
|
||||||
|
const pageIndicatorEl = document.getElementById("bm-page-indicator");
|
||||||
|
const prevBtn = document.getElementById("bm-page-prev");
|
||||||
|
const nextBtn = document.getElementById("bm-page-next");
|
||||||
|
if (!tbody) return;
|
||||||
|
tbody.innerHTML = "";
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
if (emptyEl) emptyEl.style.display = "";
|
||||||
|
if (paginatorEl) paginatorEl.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (emptyEl) emptyEl.style.display = "none";
|
||||||
|
|
||||||
|
const canControl = bmCanControl();
|
||||||
|
const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE));
|
||||||
|
const page = Math.min(Math.max(bmCurrentPage, 1), totalPages);
|
||||||
|
bmCurrentPage = page;
|
||||||
|
const startIndex = (page - 1) * BM_PAGE_SIZE;
|
||||||
|
const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length);
|
||||||
|
const pageItems = list.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const showScope = bmScope !== "general";
|
||||||
|
pageItems.forEach((bm) => {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
tr.dataset.bmId = bm.id;
|
||||||
|
const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--";
|
||||||
|
const locatorCell = bm.locator || "--";
|
||||||
|
const catCell = bm.category || "Uncategorised";
|
||||||
|
const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--";
|
||||||
|
const commentCell = bm.comment || "";
|
||||||
|
const checked = bmSelected.has(bm.id) ? " checked" : "";
|
||||||
|
const scopeBadge = showScope && bm.scope === "general" ? ' <span class="bm-scope-badge">G</span>' : "";
|
||||||
|
tr.innerHTML =
|
||||||
|
`<td class="bm-col-sel"><input type="checkbox" class="bm-row-sel" data-bm-id="${bmEsc(bm.id)}"${checked} aria-label="Select ${bmEsc(bm.name)}" /></td>` +
|
||||||
|
`<td class="bm-col-name">${bmEsc(bm.name)}${scopeBadge}</td>` +
|
||||||
|
`<td class="bm-col-freq">${bmFmtFreq(bm.freq_hz)}</td>` +
|
||||||
|
`<td class="bm-col-mode">${bmEsc(bm.mode)}</td>` +
|
||||||
|
`<td class="bm-col-bw">${bwCell}</td>` +
|
||||||
|
`<td class="bm-col-loc">${bmEsc(locatorCell)}</td>` +
|
||||||
|
`<td class="bm-col-cat">${bmEsc(catCell)}</td>` +
|
||||||
|
`<td class="bm-col-dec">${bmEsc(decoderCell)}</td>` +
|
||||||
|
`<td class="bm-col-cmt">${bmEsc(commentCell)}</td>` +
|
||||||
|
`<td class="bm-col-act">` +
|
||||||
|
`<button class="bm-tune-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Tune</button>` +
|
||||||
|
(canControl
|
||||||
|
? `<button class="bm-edit-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Edit</button>` +
|
||||||
|
`<button class="bm-del-btn" type="button" data-bm-id="${bmEsc(bm.id)}">Delete</button>`
|
||||||
|
: "") +
|
||||||
|
`</td>`;
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
|
||||||
|
if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : "";
|
||||||
|
if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`;
|
||||||
|
if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`;
|
||||||
|
if (prevBtn) prevBtn.disabled = page <= 1;
|
||||||
|
if (nextBtn) nextBtn.disabled = page >= totalPages;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmChangePage(delta) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE));
|
||||||
|
const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages);
|
||||||
|
if (nextPage === bmCurrentPage) return;
|
||||||
|
bmCurrentPage = nextPage;
|
||||||
|
bmRender(bmFilteredList);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read decoder checkboxes and return an array of selected decoder names.
|
||||||
|
function bmReadDecoders() {
|
||||||
|
return (window.decoderRegistry || [])
|
||||||
|
.filter(d => d.bookmark_selectable)
|
||||||
|
.filter(d => document.getElementById("bm-dec-" + d.id)?.checked)
|
||||||
|
.map(d => d.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set decoder checkboxes to match the given array.
|
||||||
|
function bmWriteDecoders(decoders) {
|
||||||
|
const set = new Set(decoders || []);
|
||||||
|
(window.decoderRegistry || [])
|
||||||
|
.filter(d => d.bookmark_selectable)
|
||||||
|
.forEach(d => {
|
||||||
|
const el = document.getElementById("bm-dec-" + d.id);
|
||||||
|
if (el) el.checked = set.has(d.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build decoder checkboxes dynamically from the registry.
|
||||||
|
function bmBuildDecoderCheckboxes() {
|
||||||
|
const container = document.getElementById("bm-decoder-checkboxes");
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = "";
|
||||||
|
(window.decoderRegistry || [])
|
||||||
|
.filter(d => d.bookmark_selectable)
|
||||||
|
.forEach(d => {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "bm-decoder-check";
|
||||||
|
label.innerHTML = '<input type="checkbox" id="bm-dec-' + d.id + '" value="' + d.id + '" /> ' + d.label;
|
||||||
|
container.appendChild(label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmOpenForm(bm) {
|
||||||
|
const wrap = document.getElementById("bm-form-wrap");
|
||||||
|
if (!wrap) return;
|
||||||
|
bmEditId = bm ? bm.id : null;
|
||||||
|
bmEditScope = bm ? (bm.scope || bmScope) : null;
|
||||||
|
|
||||||
|
// Rebuild decoder checkboxes from registry (handles race where registry
|
||||||
|
// loaded after initial build).
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
|
||||||
|
document.getElementById("bm-id").value = bm ? bm.id : "";
|
||||||
|
document.getElementById("bm-name").value = bm ? bm.name : "";
|
||||||
|
document.getElementById("bm-freq").value = bm ? bm.freq_hz : "";
|
||||||
|
document.getElementById("bm-mode").value = bm ? bm.mode : "";
|
||||||
|
document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : "";
|
||||||
|
document.getElementById("bm-locator").value = bm ? (bm.locator || "") : "";
|
||||||
|
document.getElementById("bm-category-input").value = bm ? (bm.category || "") : "";
|
||||||
|
document.getElementById("bm-comment").value = bm ? (bm.comment || "") : "";
|
||||||
|
bmWriteDecoders(bm ? bm.decoders : []);
|
||||||
|
document.getElementById("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark";
|
||||||
|
|
||||||
|
wrap.style.display = "flex";
|
||||||
|
document.getElementById("bm-name").focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmCloseForm() {
|
||||||
|
const wrap = document.getElementById("bm-form-wrap");
|
||||||
|
if (wrap) wrap.style.display = "none";
|
||||||
|
bmEditId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmPrefillFromStatus() {
|
||||||
|
// Use globals maintained by app.js (updated by SSE stream)
|
||||||
|
if (typeof lastFreqHz === "number" && Number.isFinite(lastFreqHz)) {
|
||||||
|
document.getElementById("bm-freq").value = Math.round(lastFreqHz);
|
||||||
|
}
|
||||||
|
if (typeof lastModeName === "string" && lastModeName) {
|
||||||
|
document.getElementById("bm-mode").value = lastModeName;
|
||||||
|
}
|
||||||
|
if (typeof currentBandwidthHz === "number" && currentBandwidthHz > 0) {
|
||||||
|
document.getElementById("bm-bw").value = Math.round(currentBandwidthHz);
|
||||||
|
}
|
||||||
|
// Prefill decoder checkboxes from current toggle button state.
|
||||||
|
const activeDecoders = (window.decoderRegistry || [])
|
||||||
|
.filter(d => d.bookmark_selectable && d.activation === "toggle")
|
||||||
|
.filter(d => {
|
||||||
|
const btn = document.getElementById(d.id + "-decode-toggle-btn");
|
||||||
|
return btn && btn.dataset.enabled === "true";
|
||||||
|
})
|
||||||
|
.map(d => d.id);
|
||||||
|
bmWriteDecoders(activeDecoders);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmSave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById("bm-id").value;
|
||||||
|
const name = document.getElementById("bm-name").value.trim();
|
||||||
|
const freqStr = document.getElementById("bm-freq").value;
|
||||||
|
const freq_hz = parseInt(freqStr, 10);
|
||||||
|
const mode = document.getElementById("bm-mode").value.trim();
|
||||||
|
const bwStr = document.getElementById("bm-bw").value;
|
||||||
|
const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null;
|
||||||
|
const locator = document.getElementById("bm-locator").value.trim().toUpperCase();
|
||||||
|
const category = document.getElementById("bm-category-input").value.trim();
|
||||||
|
const comment = document.getElementById("bm-comment").value.trim();
|
||||||
|
const decoders = bmReadDecoders();
|
||||||
|
|
||||||
|
const formError = document.getElementById("bm-form-error");
|
||||||
|
if (formError) formError.textContent = "";
|
||||||
|
if (!name || !Number.isFinite(freq_hz) || !mode) {
|
||||||
|
if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode.";
|
||||||
|
const invalid = !name ? document.getElementById("bm-name")
|
||||||
|
: !Number.isFinite(freq_hz) ? document.getElementById("bm-freq") : document.getElementById("bm-mode");
|
||||||
|
invalid?.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
name,
|
||||||
|
freq_hz,
|
||||||
|
mode,
|
||||||
|
bandwidth_hz,
|
||||||
|
locator: locator || null,
|
||||||
|
category,
|
||||||
|
comment,
|
||||||
|
decoders,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let resp;
|
||||||
|
if (id) {
|
||||||
|
resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
resp = await fetch("/bookmarks" + bmScopeParam(false), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = await resp.text();
|
||||||
|
if (resp.status === 409) {
|
||||||
|
throw new Error("A bookmark for that frequency already exists.");
|
||||||
|
}
|
||||||
|
throw new Error(text || "HTTP " + resp.status);
|
||||||
|
}
|
||||||
|
bmCloseForm();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to save bookmark:", err);
|
||||||
|
if (formError) formError.textContent = "Failed to save bookmark: " + err.message;
|
||||||
|
window.trxUi?.notify("Bookmark could not be saved", { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmDelete(id) {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return;
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm ? bm.scope : undefined;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmark:", err);
|
||||||
|
window.trxUi?.notify("Failed to delete bookmark: " + err.message, { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmApply(bm) {
|
||||||
|
try {
|
||||||
|
// --- Optimistic UI updates (instant, before any network round-trips) ---
|
||||||
|
if (typeof modeEl !== "undefined" && modeEl) {
|
||||||
|
modeEl.value = String(bm.mode || "").toUpperCase();
|
||||||
|
}
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
if (typeof currentBandwidthHz !== "undefined") {
|
||||||
|
currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
}
|
||||||
|
window.currentBandwidthHz = bm.bandwidth_hz;
|
||||||
|
if (typeof syncBandwidthInput === "function") {
|
||||||
|
syncBandwidthInput(bm.bandwidth_hz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (typeof applyLocalTunedFrequency === "function") {
|
||||||
|
// Set optimistic guard before applying so SSE cannot snap back.
|
||||||
|
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||||
|
++_freqOptimisticSeq;
|
||||||
|
_freqOptimisticHz = bm.freq_hz;
|
||||||
|
}
|
||||||
|
// Force display so the BW overlay is repositioned even when freq is unchanged.
|
||||||
|
applyLocalTunedFrequency(bm.freq_hz, true);
|
||||||
|
}
|
||||||
|
if (typeof scheduleSpectrumDraw === "function" && typeof lastSpectrumData !== "undefined" && lastSpectrumData) {
|
||||||
|
scheduleSpectrumDraw();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Take scheduler control up front, then apply mode before bandwidth so a
|
||||||
|
// late SetMode cannot revert a saved WFM bookmark bandwidth to 180 kHz.
|
||||||
|
const tunePromise = (async () => {
|
||||||
|
if (typeof vchanTakeSchedulerControl === "function") {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
}
|
||||||
|
|
||||||
|
const onVirtual = typeof vchanInterceptMode === "function"
|
||||||
|
&& await vchanInterceptMode(bm.mode);
|
||||||
|
if (!onVirtual) {
|
||||||
|
await postPath("/set_mode?mode=" + encodeURIComponent(bm.mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bm.bandwidth_hz) {
|
||||||
|
const bwHandledByVchan = typeof vchanInterceptBandwidth === "function"
|
||||||
|
&& await vchanInterceptBandwidth(bm.bandwidth_hz);
|
||||||
|
if (!bwHandledByVchan) {
|
||||||
|
await postPath("/set_bandwidth?hz=" + bm.bandwidth_hz);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setRigFrequency is wrapped by vchan.js to redirect to the channel API
|
||||||
|
// when on a virtual channel, so this call works correctly in both cases.
|
||||||
|
// It also does its own optimistic update (applyLocalTunedFrequency) but
|
||||||
|
// that's a no-op since we already set the same value above.
|
||||||
|
if (typeof setRigFrequency === "function") {
|
||||||
|
await setRigFrequency(bm.freq_hz);
|
||||||
|
} else {
|
||||||
|
await postPath("/set_freq?hz=" + bm.freq_hz);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
// Decoder toggles — fire-and-forget.
|
||||||
|
// - Decoders incompatible with the new mode are always turned off
|
||||||
|
// (even when the bookmark has no explicit decoder selection).
|
||||||
|
// - For compatible decoders, if the bookmark specifies a set, the
|
||||||
|
// toggles are driven to match that set; otherwise they're left
|
||||||
|
// alone.
|
||||||
|
const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0;
|
||||||
|
const modeUp = (bm.mode || "").toUpperCase();
|
||||||
|
const allToggleDecoders = (window.decoderRegistry || []).filter(d =>
|
||||||
|
d.activation === "toggle"
|
||||||
|
);
|
||||||
|
const decoderPromise = allToggleDecoders.length ? (async () => {
|
||||||
|
let statusUrl = "/status";
|
||||||
|
if (typeof lastActiveRigId !== "undefined" && lastActiveRigId) {
|
||||||
|
statusUrl += "?remote=" + encodeURIComponent(lastActiveRigId);
|
||||||
|
}
|
||||||
|
const statusResp = await fetch(statusUrl);
|
||||||
|
if (!statusResp.ok) return;
|
||||||
|
const st = await statusResp.json();
|
||||||
|
const toggles = [];
|
||||||
|
for (const d of allToggleDecoders) {
|
||||||
|
const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled";
|
||||||
|
const currentlyOn = !!st[statusKey];
|
||||||
|
const compatible = Array.isArray(d.active_modes)
|
||||||
|
&& d.active_modes.includes(modeUp);
|
||||||
|
let wanted;
|
||||||
|
if (!compatible) {
|
||||||
|
// Always disable decoders that don't apply to the new mode.
|
||||||
|
wanted = false;
|
||||||
|
} else if (hasDecoders) {
|
||||||
|
wanted = bm.decoders.includes(d.id);
|
||||||
|
} else {
|
||||||
|
// Mode-compatible and no bookmark selection: leave as-is.
|
||||||
|
wanted = currentlyOn;
|
||||||
|
}
|
||||||
|
if (wanted !== currentlyOn) {
|
||||||
|
toggles.push(postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (toggles.length) await Promise.all(toggles);
|
||||||
|
})() : Promise.resolve();
|
||||||
|
// Don't await — let the network calls settle in the background.
|
||||||
|
// Errors are logged but don't block the UI.
|
||||||
|
Promise.all([tunePromise, decoderPromise]).catch(
|
||||||
|
(err) => console.error("Bookmark apply background error:", err)
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to apply bookmark:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmUpdateSelectionUi() {
|
||||||
|
const count = bmSelected.size;
|
||||||
|
const canCtrl = bmCanControl();
|
||||||
|
const visible = count > 0 && canCtrl;
|
||||||
|
const btn = document.getElementById("bm-del-selected-btn");
|
||||||
|
const countEl = document.getElementById("bm-del-selected-count");
|
||||||
|
if (btn) btn.style.display = visible ? "" : "none";
|
||||||
|
if (countEl) countEl.textContent = count;
|
||||||
|
const moveWrap = document.getElementById("bm-move-selected-wrap");
|
||||||
|
const moveCountEl = document.getElementById("bm-move-selected-count");
|
||||||
|
if (moveWrap) moveWrap.style.display = visible ? "" : "none";
|
||||||
|
if (moveCountEl) moveCountEl.textContent = count;
|
||||||
|
if (visible) bmPopulateMoveTarget();
|
||||||
|
const selectAllBtn = document.getElementById("bm-select-all-btn");
|
||||||
|
if (selectAllBtn && bmCanControl()) {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Populate the move-target dropdown with all scopes except the current one. */
|
||||||
|
function bmPopulateMoveTarget() {
|
||||||
|
const sel = document.getElementById("bm-move-target");
|
||||||
|
if (!sel) return;
|
||||||
|
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||||
|
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||||
|
const prev = sel.value;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (bmScope !== "general") {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "general";
|
||||||
|
opt.textContent = "General";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
if (id === bmScope) return;
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) {
|
||||||
|
sel.value = prev;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmMoveSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
const target = document.getElementById("bm-move-target")?.value;
|
||||||
|
if (!target) return;
|
||||||
|
const targetLabel = document.getElementById("bm-move-target")?.selectedOptions[0]?.textContent || target;
|
||||||
|
if (!await window.trxUi.confirm({
|
||||||
|
title: "Move selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`,
|
||||||
|
confirmLabel: "Move",
|
||||||
|
danger: false,
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
// Group selected IDs by their owning scope (skip if already in target).
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
if (scope === target) continue;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
|
||||||
|
fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds, to: target }),
|
||||||
|
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to move bookmarks:", err);
|
||||||
|
window.trxUi?.notify("Failed to move bookmarks: " + err.message, { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmSyncSelectAllCheckbox() {
|
||||||
|
const selectAll = document.getElementById("bm-select-all");
|
||||||
|
if (!selectAll) return;
|
||||||
|
const checkboxes = document.querySelectorAll(".bm-row-sel");
|
||||||
|
if (checkboxes.length === 0) {
|
||||||
|
selectAll.checked = false;
|
||||||
|
selectAll.indeterminate = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length;
|
||||||
|
selectAll.checked = checkedCount === checkboxes.length;
|
||||||
|
selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bmDeleteSelected() {
|
||||||
|
const ids = Array.from(bmSelected);
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
if (!await window.trxUi.confirm({
|
||||||
|
title: "Delete selected bookmarks?",
|
||||||
|
message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`,
|
||||||
|
confirmLabel: "Delete",
|
||||||
|
})) return;
|
||||||
|
try {
|
||||||
|
// Group selected IDs by their owning scope.
|
||||||
|
const byScope = {};
|
||||||
|
for (const id of ids) {
|
||||||
|
const bm = bmList.find((b) => b.id === id);
|
||||||
|
const scope = bm?.scope || bmScope;
|
||||||
|
(byScope[scope] ||= []).push(id);
|
||||||
|
}
|
||||||
|
await Promise.all(Object.entries(byScope).map(([scope, scopeIds]) =>
|
||||||
|
fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ ids: scopeIds }),
|
||||||
|
}).then((r) => { if (!r.ok) throw new Error("HTTP " + r.status); })
|
||||||
|
));
|
||||||
|
bmSelected.clear();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
await bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to delete bookmarks:", err);
|
||||||
|
window.trxUi?.notify("Failed to delete bookmarks: " + err.message, { kind: "error" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Populate the scope picker with "General" + one option per rig. */
|
||||||
|
function bmPopulateScopePicker() {
|
||||||
|
const picker = document.getElementById("bm-scope-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
const rigIds = (typeof lastRigIds !== "undefined" && Array.isArray(lastRigIds)) ? lastRigIds : [];
|
||||||
|
const displayNames = (typeof lastRigDisplayNames !== "undefined") ? lastRigDisplayNames : {};
|
||||||
|
// Preserve current selection if still valid.
|
||||||
|
const prev = picker.value;
|
||||||
|
while (picker.options.length > 1) picker.remove(1);
|
||||||
|
rigIds.forEach((id) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = id;
|
||||||
|
opt.textContent = displayNames[id] || id;
|
||||||
|
picker.appendChild(opt);
|
||||||
|
});
|
||||||
|
if (prev && (prev === "general" || rigIds.includes(prev))) {
|
||||||
|
picker.value = prev;
|
||||||
|
} else {
|
||||||
|
picker.value = "general";
|
||||||
|
}
|
||||||
|
bmScope = picker.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Event wiring ---
|
||||||
|
(function initBookmarks() {
|
||||||
|
// Set initial button visibility (auth may already be resolved by the time
|
||||||
|
// scripts run if auth is disabled; otherwise bmFetch() will sync it).
|
||||||
|
bmSyncAccess();
|
||||||
|
|
||||||
|
// Build decoder checkboxes from registry. The registry is fetched async
|
||||||
|
// so we rebuild once it arrives to ensure checkboxes are present.
|
||||||
|
bmBuildDecoderCheckboxes();
|
||||||
|
if (typeof window.onDecoderRegistryReady === "function") {
|
||||||
|
window.onDecoderRegistryReady(bmBuildDecoderCheckboxes);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope picker
|
||||||
|
bmPopulateScopePicker();
|
||||||
|
const scopePicker = document.getElementById("bm-scope-picker");
|
||||||
|
if (scopePicker) {
|
||||||
|
scopePicker.addEventListener("change", (e) => {
|
||||||
|
bmScope = e.target.value;
|
||||||
|
bmFetch(document.getElementById("bm-category-filter")?.value || "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh list and sync access when the Bookmarks tab is activated
|
||||||
|
document.querySelector(".tab-bar").addEventListener("click", (e) => {
|
||||||
|
const btn = e.target.closest('.tab[data-tab="bookmarks"]');
|
||||||
|
if (!btn) return;
|
||||||
|
bmFetch(document.getElementById("bm-category-filter").value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Bookmark button — open form and prefill from current rig state
|
||||||
|
document.getElementById("bm-add-btn").addEventListener("click", () => {
|
||||||
|
bmOpenForm(null);
|
||||||
|
bmPrefillFromStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Category filter dropdown
|
||||||
|
document.getElementById("bm-category-filter").addEventListener("change", (e) => {
|
||||||
|
bmFetch(e.target.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mode filter dropdown (client-side, no re-fetch)
|
||||||
|
document.getElementById("bm-mode-filter").addEventListener("change", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Text search filter (client-side, no re-fetch)
|
||||||
|
document.getElementById("bm-text-filter").addEventListener("input", () => {
|
||||||
|
bmApplyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("bm-page-prev").addEventListener("click", () => {
|
||||||
|
bmChangePage(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("bm-page-next").addEventListener("click", () => {
|
||||||
|
bmChangePage(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Form submit
|
||||||
|
document.getElementById("bm-form").addEventListener("submit", bmSave);
|
||||||
|
|
||||||
|
// Form cancel
|
||||||
|
document.getElementById("bm-form-cancel").addEventListener("click", bmCloseForm);
|
||||||
|
|
||||||
|
const formWrap = document.getElementById("bm-form-wrap");
|
||||||
|
if (formWrap) {
|
||||||
|
formWrap.addEventListener("click", (e) => {
|
||||||
|
if (e.target === formWrap) bmCloseForm();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Escape" && document.getElementById("bm-form-wrap")?.style.display === "flex") {
|
||||||
|
bmCloseForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select-all checkbox
|
||||||
|
document.getElementById("bm-select-all").addEventListener("change", (e) => {
|
||||||
|
const checked = e.target.checked;
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = checked;
|
||||||
|
if (checked) bmSelected.add(cb.dataset.bmId);
|
||||||
|
else bmSelected.delete(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select All (across all pages) button
|
||||||
|
document.getElementById("bm-select-all-btn").addEventListener("click", () => {
|
||||||
|
const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id));
|
||||||
|
if (allSelected) {
|
||||||
|
bmSelected.clear();
|
||||||
|
} else {
|
||||||
|
bmFilteredList.forEach((bm) => bmSelected.add(bm.id));
|
||||||
|
}
|
||||||
|
// Sync visible page checkboxes
|
||||||
|
document.querySelectorAll(".bm-row-sel").forEach((cb) => {
|
||||||
|
cb.checked = bmSelected.has(cb.dataset.bmId);
|
||||||
|
});
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Selected button
|
||||||
|
document.getElementById("bm-del-selected-btn").addEventListener("click", () => {
|
||||||
|
bmDeleteSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move Selected button
|
||||||
|
document.getElementById("bm-move-selected-btn").addEventListener("click", () => {
|
||||||
|
bmMoveSelected();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table action buttons and row checkboxes (event delegation)
|
||||||
|
document.getElementById("bm-tbody").addEventListener("click", async (e) => {
|
||||||
|
const checkbox = e.target.closest(".bm-row-sel");
|
||||||
|
if (checkbox) {
|
||||||
|
if (checkbox.checked) bmSelected.add(checkbox.dataset.bmId);
|
||||||
|
else bmSelected.delete(checkbox.dataset.bmId);
|
||||||
|
bmSyncSelectAllCheckbox();
|
||||||
|
bmUpdateSelectionUi();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tuneBtn = e.target.closest(".bm-tune-btn");
|
||||||
|
const editBtn = e.target.closest(".bm-edit-btn");
|
||||||
|
const delBtn = e.target.closest(".bm-del-btn");
|
||||||
|
|
||||||
|
if (tuneBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId);
|
||||||
|
if (bm) await bmApply(bm);
|
||||||
|
} else if (editBtn) {
|
||||||
|
const bm = bmList.find((b) => b.id === editBtn.dataset.bmId);
|
||||||
|
if (bm) bmOpenForm(bm);
|
||||||
|
} else if (delBtn) {
|
||||||
|
await bmDelete(delBtn.dataset.bmId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pre-load bookmarks so spectrum markers are visible immediately.
|
||||||
|
bmFetch("");
|
||||||
|
})();
|
||||||
@@ -0,0 +1,451 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- CW (Morse) Decoder Plugin (server-side decode) ---
|
||||||
|
const cwStatusEl = document.getElementById("cw-status");
|
||||||
|
const cwOutputEl = document.getElementById("cw-output");
|
||||||
|
const cwAutoInput = document.getElementById("cw-auto");
|
||||||
|
const cwWpmInput = document.getElementById("cw-wpm");
|
||||||
|
const cwToneInput = document.getElementById("cw-tone");
|
||||||
|
const cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
|
const cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
|
const cwToneGl = typeof createTrxWebGlRenderer === "function"
|
||||||
|
? createTrxWebGlRenderer(cwToneCanvas, { alpha: true })
|
||||||
|
: null;
|
||||||
|
const cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
|
const cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
|
const cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
|
const CW_MAX_LINES = 200;
|
||||||
|
const CW_TONE_MIN_HZ = 100;
|
||||||
|
const CW_TONE_MAX_HZ = 10_000;
|
||||||
|
const CW_WPM_MIN = 5;
|
||||||
|
const CW_WPM_MAX = 40;
|
||||||
|
const CW_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const CW_BAR_LINE_GAP_MS = 5000;
|
||||||
|
let cwLastAppendTime = 0;
|
||||||
|
let cwTonePickerRaf = null;
|
||||||
|
let cwBarHistory = []; // [{tsMs, ts, text, wpm, tone_hz}]
|
||||||
|
let cwBarCurrentLine = null; // accumulates chars until gap/newline
|
||||||
|
let cwBarDismissedAtMs = 0;
|
||||||
|
// Tracks a user-initiated auto toggle that is in-flight (POST not yet
|
||||||
|
// acknowledged). While set, server-state updates must not override the
|
||||||
|
// checkbox so that a concurrent SSE event carrying the *old* cw_auto value
|
||||||
|
// does not immediately undo the user's choice.
|
||||||
|
let cwAutoLocalOverride = null;
|
||||||
|
|
||||||
|
function applyCwAutoUi(enabled) {
|
||||||
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
|
if (cwWpmInput) {
|
||||||
|
cwWpmInput.disabled = enabled;
|
||||||
|
cwWpmInput.readOnly = enabled;
|
||||||
|
}
|
||||||
|
if (cwToneInput) {
|
||||||
|
cwToneInput.disabled = enabled;
|
||||||
|
cwToneInput.readOnly = enabled;
|
||||||
|
}
|
||||||
|
if (cwTonePickerEl) {
|
||||||
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.applyCwAutoUi = applyCwAutoUi;
|
||||||
|
|
||||||
|
// Called by app.js render() when a server-state snapshot arrives. Ignores
|
||||||
|
// the update while cwAutoLocalOverride is set (user change still in-flight).
|
||||||
|
window.applyCwAutoUiFromServer = function(enabled) {
|
||||||
|
if (cwAutoLocalOverride !== null) return;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
};
|
||||||
|
|
||||||
|
function cwBarFlushCurrentLine() {
|
||||||
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
|
}
|
||||||
|
cwBarCurrentLine = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCwBar() {
|
||||||
|
if (!cwBarOverlay) return;
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const isCw = mode === "CW" || mode === "CWR";
|
||||||
|
const cutoffMs = Date.now() - CW_BAR_WINDOW_MS;
|
||||||
|
const recent = cwBarHistory.filter((l) => l.tsMs >= cutoffMs);
|
||||||
|
// Prepend the in-progress line so characters appear immediately
|
||||||
|
const liveLines = cwBarCurrentLine && cwBarCurrentLine.text ? [cwBarCurrentLine, ...recent] : recent;
|
||||||
|
const newestTsMs = liveLines.reduce((latest, line) => Math.max(latest, Number(line.tsMs) || 0), 0);
|
||||||
|
if (!isCw || liveLines.length === 0 || newestTsMs <= cwBarDismissedAtMs) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html =
|
||||||
|
'<div class="aprs-bar-header">' +
|
||||||
|
'<span class="aprs-bar-title"><span class="aprs-bar-title-word">CW</span><span class="aprs-bar-title-word">Live</span></span>' +
|
||||||
|
'<span class="aprs-bar-actions">' +
|
||||||
|
'<span class="aprs-bar-window">Last 15 minutes</span>' +
|
||||||
|
'<span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0"' +
|
||||||
|
' onclick="window.clearCwBar()"' +
|
||||||
|
' onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearCwBar();}"' +
|
||||||
|
' aria-label="Clear CW overlay">Clear</span></span>' +
|
||||||
|
'<button class="aprs-bar-close" type="button" onclick="window.closeCwBar()" aria-label="Close CW overlay">×</button>' +
|
||||||
|
'</span>' +
|
||||||
|
'</div>';
|
||||||
|
for (const line of liveLines.slice(0, 8)) {
|
||||||
|
const ts = line.ts ? `<span class="aprs-bar-time">${line.ts}</span>` : "";
|
||||||
|
const meta = [
|
||||||
|
line.wpm ? `${line.wpm} WPM` : null,
|
||||||
|
line.tone_hz ? `${line.tone_hz} Hz` : null,
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame">` +
|
||||||
|
`<div class="aprs-bar-frame-main">${ts}${escapeMapHtml(line.text)}` +
|
||||||
|
(meta ? ` <span class="aprs-bar-time">${escapeMapHtml(meta)}</span>` : "") +
|
||||||
|
`</div></div>`;
|
||||||
|
}
|
||||||
|
cwBarOverlay.innerHTML = html;
|
||||||
|
cwBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateCwBar = updateCwBar;
|
||||||
|
window.clearCwBar = function() {
|
||||||
|
window.resetCwHistoryView();
|
||||||
|
};
|
||||||
|
window.closeCwBar = function() {
|
||||||
|
cwBarDismissedAtMs = Date.now();
|
||||||
|
if (cwBarOverlay) {
|
||||||
|
cwBarOverlay.style.display = "none";
|
||||||
|
cwBarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function clampCwWpm(wpm) {
|
||||||
|
const numeric = Number(wpm);
|
||||||
|
if (!Number.isFinite(numeric)) return 15;
|
||||||
|
return Math.round(Math.max(CW_WPM_MIN, Math.min(CW_WPM_MAX, numeric)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampCwTone(tone) {
|
||||||
|
const numeric = Number(tone);
|
||||||
|
if (!Number.isFinite(numeric)) return 700;
|
||||||
|
return Math.round(Math.max(CW_TONE_MIN_HZ, Math.min(CW_TONE_MAX_HZ, numeric)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentCwToneRange() {
|
||||||
|
const tunedHz = Number.isFinite(window.lastFreqHz) ? Number(window.lastFreqHz) : NaN;
|
||||||
|
const bandwidthHz = Number.isFinite(window.currentBandwidthHz) ? Number(window.currentBandwidthHz) : NaN;
|
||||||
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const lowerSideband = mode === "CWR";
|
||||||
|
const upperSideband = mode === "CW";
|
||||||
|
if (!lowerSideband && !upperSideband) return null;
|
||||||
|
|
||||||
|
const toneMinHz = CW_TONE_MIN_HZ;
|
||||||
|
const toneMaxHz = CW_TONE_MAX_HZ;
|
||||||
|
if (toneMaxHz < toneMinHz) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tunedHz,
|
||||||
|
bandwidthHz,
|
||||||
|
toneMinHz,
|
||||||
|
toneMaxHz,
|
||||||
|
toneSpanHz: Math.max(1, toneMaxHz - toneMinHz),
|
||||||
|
lowerSideband,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cwToneToRfHz(range, toneHz) {
|
||||||
|
if (!range) return NaN;
|
||||||
|
return range.lowerSideband
|
||||||
|
? range.tunedHz - toneHz
|
||||||
|
: range.tunedHz + toneHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toneClampForRange(tone, range) {
|
||||||
|
const clamped = clampCwTone(tone);
|
||||||
|
if (!range) return clamped;
|
||||||
|
return Math.max(range.toneMinHz, Math.min(range.toneMaxHz, clamped));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCwToneCanvasResolution() {
|
||||||
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return false;
|
||||||
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
|
const cssWidth = Math.round(rect.width);
|
||||||
|
const cssHeight = Math.round(rect.height);
|
||||||
|
if (cssWidth < 8 || cssHeight < 8) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
return cwToneGl.ensureSize(cssWidth, cssHeight, dpr);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawCwTonePicker() {
|
||||||
|
if (!cwToneCanvas || !cwToneGl || !cwToneGl.ready) return;
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
if (cwToneCanvas.width < 8 || cwToneCanvas.height < 8) return;
|
||||||
|
const width = cwToneCanvas.width;
|
||||||
|
const height = cwToneCanvas.height;
|
||||||
|
cwToneGl.clear([0, 0, 0, 0]);
|
||||||
|
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length || !range) {
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const mode = String(document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
|
} else if (!window.lastSpectrumData || !Array.isArray(window.lastSpectrumData.bins) || !window.lastSpectrumData.bins.length) {
|
||||||
|
cwToneRangeEl.textContent = "Waiting for spectrum";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [130 / 255, 150 / 255, 165 / 255, 0.22]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const side = range.lowerSideband ? "Lower side" : "Upper side";
|
||||||
|
cwToneRangeEl.textContent = `Audio ${range.toneMinHz}-${range.toneMaxHz} Hz · ${side}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bins = window.lastSpectrumData.bins;
|
||||||
|
const sampleRate = Number(window.lastSpectrumData.sample_rate);
|
||||||
|
const centerHz = Number(window.lastSpectrumData.center_hz);
|
||||||
|
const maxIdx = Math.max(1, bins.length - 1);
|
||||||
|
const fullLoHz = centerHz - sampleRate / 2;
|
||||||
|
const tones = new Array(width).fill(-140);
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
const frac = width <= 1 ? 0 : x / (width - 1);
|
||||||
|
const toneHz = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
|
const rfHz = cwToneToRfHz(range, toneHz);
|
||||||
|
const idx = Math.max(0, Math.min(maxIdx, Math.round((((rfHz - fullLoHz) / sampleRate) * maxIdx))));
|
||||||
|
const power = Number.isFinite(Number(bins[idx])) ? Number(bins[idx]) : -140;
|
||||||
|
tones[x] = power;
|
||||||
|
}
|
||||||
|
|
||||||
|
const smoothed = new Array(width).fill(-140);
|
||||||
|
const smoothRadius = Math.max(1, Math.round(width / 180));
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
let sum = 0;
|
||||||
|
let count = 0;
|
||||||
|
for (let i = x - smoothRadius; i <= x + smoothRadius; i += 1) {
|
||||||
|
if (i < 0 || i >= width) continue;
|
||||||
|
sum += tones[i];
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
smoothed[x] = count > 0 ? sum / count : tones[x];
|
||||||
|
}
|
||||||
|
|
||||||
|
const sorted = smoothed.slice().sort((a, b) => a - b);
|
||||||
|
const q20 = sorted[Math.floor((sorted.length - 1) * 0.2)] ?? -120;
|
||||||
|
const q95 = sorted[Math.floor((sorted.length - 1) * 0.95)] ?? -70;
|
||||||
|
const floorDb = Math.min(q20 - 2, q95 - 10);
|
||||||
|
const ceilDb = Math.max(floorDb + 18, q95 + 2);
|
||||||
|
const dbSpan = Math.max(1, ceilDb - floorDb);
|
||||||
|
const yForDb = (db) => {
|
||||||
|
const n = Math.max(0, Math.min(1, (db - floorDb) / dbSpan));
|
||||||
|
return Math.round((1 - n) * (height - 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootStyle = getComputedStyle(document.documentElement);
|
||||||
|
const accent = (rootStyle.getPropertyValue("--accent-green") || "").trim() || "#00d17f";
|
||||||
|
const parseColor = typeof window.trxParseCssColor === "function"
|
||||||
|
? window.trxParseCssColor
|
||||||
|
: null;
|
||||||
|
const accentRgba = parseColor ? parseColor(accent) : [0, 0.82, 0.5, 1];
|
||||||
|
const axisColor = [230 / 255, 235 / 255, 245 / 255, 0.15];
|
||||||
|
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [7 / 255, 12 / 255, 18 / 255, 0.94]);
|
||||||
|
|
||||||
|
const hGridCount = 4;
|
||||||
|
const gridSegments = [];
|
||||||
|
for (let i = 1; i <= hGridCount; i += 1) {
|
||||||
|
const y = Math.round((i / (hGridCount + 1)) * (height - 1));
|
||||||
|
gridSegments.push(0, y, width, y);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(gridSegments, axisColor, 1);
|
||||||
|
|
||||||
|
const toneStep = range.toneSpanHz <= 500 ? 50 : range.toneSpanHz <= 1000 ? 100 : 200;
|
||||||
|
const firstTick = Math.ceil(range.toneMinHz / toneStep) * toneStep;
|
||||||
|
const tickSegments = [];
|
||||||
|
for (let tone = firstTick; tone <= range.toneMaxHz; tone += toneStep) {
|
||||||
|
const frac = (tone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const x = Math.max(0, Math.min(width - 1, Math.round(frac * (width - 1))));
|
||||||
|
tickSegments.push(x, 0, x, height);
|
||||||
|
}
|
||||||
|
cwToneGl.drawSegments(tickSegments, axisColor, 1);
|
||||||
|
|
||||||
|
const linePoints = [];
|
||||||
|
for (let x = 0; x < width; x += 1) {
|
||||||
|
linePoints.push(x, yForDb(smoothed[x]));
|
||||||
|
}
|
||||||
|
cwToneGl.drawFilledArea(linePoints, height, [accentRgba[0], accentRgba[1], accentRgba[2], 0.24]);
|
||||||
|
cwToneGl.drawPolyline(linePoints, accentRgba, Math.max(1.2, (window.devicePixelRatio || 1) * 1.2));
|
||||||
|
|
||||||
|
const currentTone = toneClampForRange(cwToneInput ? cwToneInput.value : 700, range);
|
||||||
|
const markerFrac = (currentTone - range.toneMinHz) / range.toneSpanHz;
|
||||||
|
const markerX = Math.max(0, Math.min(width - 1, Math.round(markerFrac * (width - 1))));
|
||||||
|
const markerY = yForDb(smoothed[Math.max(0, Math.min(width - 1, markerX))]);
|
||||||
|
cwToneGl.drawSegments([markerX, 0, markerX, height], [1, 1, 1, 0.9], 1.5);
|
||||||
|
cwToneGl.drawPoints([markerX, markerY], Math.max(2, Math.round(height * 0.055)), [1, 1, 1, 0.9]);
|
||||||
|
|
||||||
|
if (cwAutoInput?.checked) {
|
||||||
|
cwToneGl.fillRect(0, 0, width, height, [0, 0, 0, 0.22]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setCwTone(tone, { syncInput = true } = {}) {
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
const clamped = toneClampForRange(tone, range);
|
||||||
|
if (cwToneInput && syncInput) {
|
||||||
|
cwToneInput.value = clamped;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await postPath(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW tone set failed", e);
|
||||||
|
}
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwAutoInput) {
|
||||||
|
cwAutoInput.addEventListener("change", async () => {
|
||||||
|
const enabled = cwAutoInput.checked;
|
||||||
|
cwAutoLocalOverride = enabled;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
|
try {
|
||||||
|
await postPath(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
|
drawCwTonePicker();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW auto toggle failed", e);
|
||||||
|
} finally {
|
||||||
|
cwAutoLocalOverride = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwWpmInput) {
|
||||||
|
cwWpmInput.addEventListener("change", async () => {
|
||||||
|
if (cwAutoInput && cwAutoInput.checked) return;
|
||||||
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
|
cwWpmInput.value = wpm;
|
||||||
|
try { await postPath(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`); }
|
||||||
|
catch (e) { console.error("CW WPM set failed", e); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwToneInput) {
|
||||||
|
cwToneInput.addEventListener("change", async () => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
await setCwTone(cwToneInput.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cwToneCanvas) {
|
||||||
|
cwToneCanvas.addEventListener("click", async (event) => {
|
||||||
|
if (cwAutoInput?.checked) return;
|
||||||
|
const rect = cwToneCanvas.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0) return;
|
||||||
|
const range = currentCwToneRange();
|
||||||
|
if (!range) return;
|
||||||
|
const frac = Math.max(0, Math.min(1, (event.clientX - rect.left) / rect.width));
|
||||||
|
const tone = range.toneMinHz + frac * range.toneSpanHz;
|
||||||
|
await setCwTone(tone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.resetCwHistoryView = function() {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
cwBarHistory = [];
|
||||||
|
cwBarCurrentLine = null;
|
||||||
|
updateCwBar();
|
||||||
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_cw_decode");
|
||||||
|
window.resetCwHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Server-side CW decode handler ---
|
||||||
|
window.onServerCw = function(evt) {
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
if (evt.text && cwOutputEl) {
|
||||||
|
// Append decoded text to output
|
||||||
|
const now = Date.now();
|
||||||
|
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 10000 || evt.text === "\n") {
|
||||||
|
const line = document.createElement("div");
|
||||||
|
line.className = "cw-line";
|
||||||
|
cwOutputEl.appendChild(line);
|
||||||
|
}
|
||||||
|
cwLastAppendTime = now;
|
||||||
|
const lastLine = cwOutputEl.lastElementChild;
|
||||||
|
if (lastLine) {
|
||||||
|
lastLine.textContent += evt.text;
|
||||||
|
}
|
||||||
|
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
||||||
|
cwOutputEl.removeChild(cwOutputEl.firstChild);
|
||||||
|
}
|
||||||
|
cwOutputEl.scrollTop = cwOutputEl.scrollHeight;
|
||||||
|
}
|
||||||
|
// Bar history accumulation (regardless of pause state)
|
||||||
|
if (evt.text) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (evt.text === "\n") {
|
||||||
|
cwBarFlushCurrentLine();
|
||||||
|
} else {
|
||||||
|
if (!cwBarCurrentLine || now - cwBarCurrentLine.lastMs > CW_BAR_LINE_GAP_MS) {
|
||||||
|
cwBarFlushCurrentLine();
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
cwBarCurrentLine.text += evt.text;
|
||||||
|
cwBarCurrentLine.lastMs = now;
|
||||||
|
if (Number.isFinite(Number(evt.wpm))) cwBarCurrentLine.wpm = clampCwWpm(evt.wpm);
|
||||||
|
if (Number.isFinite(Number(evt.tone_hz))) cwBarCurrentLine.tone_hz = Math.round(Number(evt.tone_hz));
|
||||||
|
}
|
||||||
|
updateCwBar();
|
||||||
|
}
|
||||||
|
if (cwSignalIndicator) {
|
||||||
|
cwSignalIndicator.className = evt.signal_on ? "cw-signal-on" : "cw-signal-off";
|
||||||
|
}
|
||||||
|
if (!cwAutoInput || cwAutoInput.checked) {
|
||||||
|
if (cwWpmInput && Number.isFinite(Number(evt.wpm))) {
|
||||||
|
cwWpmInput.value = clampCwWpm(evt.wpm);
|
||||||
|
}
|
||||||
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
|
cwToneInput.value = toneClampForRange(evt.tone_hz, currentCwToneRange());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cwTonePickerRaf != null) return;
|
||||||
|
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||||
|
cwTonePickerRaf = null;
|
||||||
|
drawCwTonePicker();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreCwHistory = function(events) {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
for (const evt of events) {
|
||||||
|
window.onServerCw(evt);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
|
};
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||||
|
});
|
||||||
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||||
|
updateCwBar();
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// --- FT2 Decoder Plugin (server-side decode) ---
|
||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
function ft8RenderMessageFt2(message) {
|
||||||
|
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||||
|
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ft2Status = document.getElementById("ft2-status");
|
||||||
|
const ft2PeriodEl = document.getElementById("ft2-period");
|
||||||
|
const ft2MessagesEl = document.getElementById("ft2-messages");
|
||||||
|
const ft2FilterInput = document.getElementById("ft2-filter");
|
||||||
|
const FT2_PERIOD_MS = 3750;
|
||||||
|
const FT2_MAX_DOM_ROWS = 200;
|
||||||
|
let ft2FilterText = "";
|
||||||
|
let ft2MessageHistory = [];
|
||||||
|
|
||||||
|
function currentFt2HistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneFt2MessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentFt2HistoryRetentionMs();
|
||||||
|
ft2MessageHistory = ft2MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt2Ui(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt2HistoryRender() { scheduleFt2Ui("ft2-history", () => renderFt2History()); }
|
||||||
|
|
||||||
|
function normalizeFt2DisplayFreqHz(freqHz) {
|
||||||
|
const rawHz = Number(freqHz);
|
||||||
|
if (!Number.isFinite(rawHz)) return null;
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||||
|
return baseHz + rawHz;
|
||||||
|
}
|
||||||
|
return rawHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFt2PeriodTimer() {
|
||||||
|
if (!ft2PeriodEl) return;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const remaining = (FT2_PERIOD_MS - nowMs % FT2_PERIOD_MS) / 1000;
|
||||||
|
ft2PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFt2PeriodTimer();
|
||||||
|
setInterval(updateFt2PeriodTimer, 250);
|
||||||
|
|
||||||
|
function renderFt2Row(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const rawMessage = (msg.message || "").toString();
|
||||||
|
row.dataset.message = rawMessage.toUpperCase();
|
||||||
|
row.dataset.decoder = "ft2";
|
||||||
|
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||||
|
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||||
|
const renderedMessage = ft8RenderMessageFt2(rawMessage);
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||||
|
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFt2History() {
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
if (!ft2MessagesEl) return;
|
||||||
|
const filter = ft2FilterText;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let rendered = 0;
|
||||||
|
for (let i = 0; i < ft2MessageHistory.length && rendered < FT2_MAX_DOM_ROWS; i++) {
|
||||||
|
const msg = ft2MessageHistory[i];
|
||||||
|
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||||
|
fragment.appendChild(renderFt2Row(msg));
|
||||||
|
rendered++;
|
||||||
|
}
|
||||||
|
ft2MessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFt2Message(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
ft2MessageHistory.unshift(msg);
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft2");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt2HistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerFt2Message(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||||
|
const grids = locatorDetails.length > 0
|
||||||
|
? locatorDetails.map((d) => d.grid)
|
||||||
|
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
||||||
|
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||||
|
const rfHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||||
|
return {
|
||||||
|
raw, grids, station, rfHz, locatorDetails,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
||||||
|
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||||
|
message: msg.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerFt2Batch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerFt2Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
ft2MessageHistory = normalized.concat(ft2MessageHistory);
|
||||||
|
pruneFt2MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft2");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt2HistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreFt2History = function(messages) { window.onServerFt2Batch(messages); };
|
||||||
|
window.pruneFt2HistoryView = function() { pruneFt2MessageHistory(); renderFt2History(); };
|
||||||
|
|
||||||
|
window.resetFt2HistoryView = function() {
|
||||||
|
if (ft2MessagesEl) ft2MessagesEl.innerHTML = "";
|
||||||
|
ft2MessageHistory = [];
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
renderFt2History();
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildFt2BarFrames() {
|
||||||
|
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
||||||
|
const messages = ft2MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||||
|
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return { count: 0, newestTsMs: 0, html: "" };
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (const msg of messages) {
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||||
|
const displayFreqHz = normalizeFt2DisplayFreqHz(msg.freq_hz);
|
||||||
|
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||||
|
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||||
|
const text = ft8RenderMessageFt2((msg.message || "").toString());
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: messages.length, newestTsMs, html };
|
||||||
|
}
|
||||||
|
window.registerFt8FamilyBarRenderer?.("ft2", buildFt2BarFrames);
|
||||||
|
|
||||||
|
if (ft2FilterInput) {
|
||||||
|
ft2FilterInput.addEventListener("input", () => {
|
||||||
|
ft2FilterText = ft2FilterInput.value.trim().toUpperCase();
|
||||||
|
renderFt2History();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ft2DecodeToggleBtn = document.getElementById("ft2-decode-toggle-btn");
|
||||||
|
ft2DecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(ft2DecodeToggleBtn);
|
||||||
|
await postPath("/toggle_ft2_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT2 toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-ft2-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear FT2 history?", message: "All stored FT2 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ft2_decode");
|
||||||
|
window.resetFt2HistoryView();
|
||||||
|
} catch (e) { console.error("FT2 history clear failed", e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onServerFt2 = function(msg) {
|
||||||
|
if (ft2Status) ft2Status.textContent = "Receiving";
|
||||||
|
const next = normalizeServerFt2Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft2", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
addFt2Message(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
// --- FT4 Decoder Plugin (server-side decode) ---
|
||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
function ft8RenderMessage(message) {
|
||||||
|
if (typeof renderFt8Message === "function") return renderFt8Message(message);
|
||||||
|
if (typeof ft8EscapeHtml === "function") return ft8EscapeHtml(message);
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ft4Status = document.getElementById("ft4-status");
|
||||||
|
const ft4PeriodEl = document.getElementById("ft4-period");
|
||||||
|
const ft4MessagesEl = document.getElementById("ft4-messages");
|
||||||
|
const ft4FilterInput = document.getElementById("ft4-filter");
|
||||||
|
const FT4_PERIOD_MS = 7500;
|
||||||
|
const FT4_MAX_DOM_ROWS = 200;
|
||||||
|
let ft4FilterText = "";
|
||||||
|
let ft4MessageHistory = [];
|
||||||
|
|
||||||
|
function currentFt4HistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneFt4MessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentFt4HistoryRetentionMs();
|
||||||
|
ft4MessageHistory = ft4MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt4Ui(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt4HistoryRender() { scheduleFt4Ui("ft4-history", () => renderFt4History()); }
|
||||||
|
|
||||||
|
function normalizeFt4DisplayFreqHz(freqHz) {
|
||||||
|
const rawHz = Number(freqHz);
|
||||||
|
if (!Number.isFinite(rawHz)) return null;
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||||
|
return baseHz + rawHz;
|
||||||
|
}
|
||||||
|
return rawHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFt4PeriodTimer() {
|
||||||
|
if (!ft4PeriodEl) return;
|
||||||
|
const nowMs = Date.now();
|
||||||
|
const remaining = (FT4_PERIOD_MS - nowMs % FT4_PERIOD_MS) / 1000;
|
||||||
|
ft4PeriodEl.textContent = `Next slot ${remaining.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFt4PeriodTimer();
|
||||||
|
setInterval(updateFt4PeriodTimer, 250);
|
||||||
|
|
||||||
|
function renderFt4Row(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const rawMessage = (msg.message || "").toString();
|
||||||
|
row.dataset.message = rawMessage.toUpperCase();
|
||||||
|
row.dataset.decoder = "ft4";
|
||||||
|
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||||
|
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||||
|
const renderedMessage = ft8RenderMessage(rawMessage);
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const timeStr = tsMs ? new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }) : "--:--:--";
|
||||||
|
row.innerHTML = `<span class="ft8-time">${timeStr}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFt4History() {
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
if (!ft4MessagesEl) return;
|
||||||
|
const filter = ft4FilterText;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let rendered = 0;
|
||||||
|
for (let i = 0; i < ft4MessageHistory.length && rendered < FT4_MAX_DOM_ROWS; i++) {
|
||||||
|
const msg = ft4MessageHistory[i];
|
||||||
|
if (filter && !(msg.message || "").toString().toUpperCase().includes(filter)) continue;
|
||||||
|
fragment.appendChild(renderFt4Row(msg));
|
||||||
|
rendered++;
|
||||||
|
}
|
||||||
|
ft4MessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFt4Message(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
ft4MessageHistory.unshift(msg);
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft4");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt4HistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerFt4Message(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const locatorDetails = typeof ft8ExtractLocatorDetails === "function" ? ft8ExtractLocatorDetails(raw) : [];
|
||||||
|
const grids = locatorDetails.length > 0
|
||||||
|
? locatorDetails.map((d) => d.grid)
|
||||||
|
: (typeof ft8ExtractAllGrids === "function" ? ft8ExtractAllGrids(raw) : []);
|
||||||
|
const station = typeof ft8ExtractLikelyCallsign === "function" ? ft8ExtractLikelyCallsign(raw) : null;
|
||||||
|
const rfHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||||
|
return {
|
||||||
|
raw, grids, station, rfHz, locatorDetails,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms, snr_db: msg.snr_db, dt_s: msg.dt_s,
|
||||||
|
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||||
|
message: msg.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerFt4Batch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerFt4Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
ft4MessageHistory = normalized.concat(ft4MessageHistory);
|
||||||
|
pruneFt4MessageHistory();
|
||||||
|
window.setFt8FamilyBarDecoder?.("ft4");
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
scheduleFt4HistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreFt4History = function(messages) { window.onServerFt4Batch(messages); };
|
||||||
|
window.pruneFt4HistoryView = function() { pruneFt4MessageHistory(); renderFt4History(); };
|
||||||
|
|
||||||
|
window.resetFt4HistoryView = function() {
|
||||||
|
if (ft4MessagesEl) ft4MessagesEl.innerHTML = "";
|
||||||
|
ft4MessageHistory = [];
|
||||||
|
window.updateFt8Bar?.();
|
||||||
|
renderFt4History();
|
||||||
|
};
|
||||||
|
|
||||||
|
function buildFt4BarFrames() {
|
||||||
|
const cutoffMs = Date.now() - 15 * 60 * 1000;
|
||||||
|
const messages = ft4MessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||||
|
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg._tsMs ?? msg.ts_ms) || 0), 0);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return { count: 0, newestTsMs: 0, html: "" };
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (const msg of messages) {
|
||||||
|
const tsMs = msg._tsMs ?? msg.ts_ms;
|
||||||
|
const ts = tsMs ? `<span class="aprs-bar-time">${fmtTime(tsMs)}</span>` : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||||
|
const displayFreqHz = normalizeFt4DisplayFreqHz(msg.freq_hz);
|
||||||
|
const rf = Number.isFinite(displayFreqHz) ? `${displayFreqHz.toFixed(0)} Hz` : null;
|
||||||
|
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||||
|
const text = ft8RenderMessage((msg.message || "").toString());
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: messages.length, newestTsMs, html };
|
||||||
|
}
|
||||||
|
window.registerFt8FamilyBarRenderer?.("ft4", buildFt4BarFrames);
|
||||||
|
|
||||||
|
if (ft4FilterInput) {
|
||||||
|
ft4FilterInput.addEventListener("input", () => {
|
||||||
|
ft4FilterText = ft4FilterInput.value.trim().toUpperCase();
|
||||||
|
renderFt4History();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ft4DecodeToggleBtn = document.getElementById("ft4-decode-toggle-btn");
|
||||||
|
ft4DecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(ft4DecodeToggleBtn);
|
||||||
|
await postPath("/toggle_ft4_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT4 toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-ft4-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear FT4 history?", message: "All stored FT4 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ft4_decode");
|
||||||
|
window.resetFt4HistoryView();
|
||||||
|
} catch (e) { console.error("FT4 history clear failed", e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onServerFt4 = function(msg) {
|
||||||
|
if (ft4Status) ft4Status.textContent = "Receiving";
|
||||||
|
const next = normalizeServerFt4Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft4", next.station, { ...msg, freq_hz: next.rfHz, locator_details: next.locatorDetails });
|
||||||
|
}
|
||||||
|
addFt4Message(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- FT8 Decoder Plugin (server-side decode) ---
|
||||||
|
const ft8Status = document.getElementById("ft8-status");
|
||||||
|
const ft8PeriodEl = document.getElementById("ft8-period");
|
||||||
|
const ft8MessagesEl = document.getElementById("ft8-messages");
|
||||||
|
const ft8FilterInput = document.getElementById("ft8-filter");
|
||||||
|
const ft8BarOverlay = document.getElementById("ft8-bar-overlay");
|
||||||
|
const FT8_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
const FT8_PERIOD_SECONDS = 15;
|
||||||
|
const FT8_MAX_DOM_ROWS = 200;
|
||||||
|
const FT8_BAR_DECODER_LABELS = {
|
||||||
|
ft8: "FT8",
|
||||||
|
ft4: "FT4",
|
||||||
|
ft2: "FT2",
|
||||||
|
};
|
||||||
|
let ft8FilterText = "";
|
||||||
|
let ft8MessageHistory = [];
|
||||||
|
let ft8BarActiveDecoder = "ft8";
|
||||||
|
const ft8BarBuilders = {};
|
||||||
|
const ft8BarDismissedAtMsByDecoder = {
|
||||||
|
ft8: 0,
|
||||||
|
ft4: 0,
|
||||||
|
ft2: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
function currentFt8HistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneFt8MessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentFt8HistoryRetentionMs();
|
||||||
|
ft8MessageHistory = ft8MessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt8Ui(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt8HistoryRender() {
|
||||||
|
scheduleFt8Ui("ft8-history", () => renderFt8History());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleFt8BarUpdate() {
|
||||||
|
scheduleFt8Ui("ft8-bar", () => updateFt8Bar());
|
||||||
|
}
|
||||||
|
|
||||||
|
window.registerFt8FamilyBarRenderer = function(decoder, builder) {
|
||||||
|
if (!FT8_BAR_DECODER_LABELS[decoder] || typeof builder !== "function") return;
|
||||||
|
ft8BarBuilders[decoder] = builder;
|
||||||
|
};
|
||||||
|
|
||||||
|
window.setFt8FamilyBarDecoder = function(decoder) {
|
||||||
|
if (!FT8_BAR_DECODER_LABELS[decoder]) return;
|
||||||
|
ft8BarActiveDecoder = decoder;
|
||||||
|
scheduleFt8BarUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeFt8DisplayFreqHz(freqHz) {
|
||||||
|
const rawHz = Number(freqHz);
|
||||||
|
if (!Number.isFinite(rawHz)) return null;
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
if (Number.isFinite(baseHz) && baseHz > 0 && rawHz >= 0 && rawHz < 100000) {
|
||||||
|
return baseHz + rawHz;
|
||||||
|
}
|
||||||
|
return rawHz;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(tsMs) {
|
||||||
|
if (!tsMs) return "--:--:--";
|
||||||
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFt8PeriodTimer() {
|
||||||
|
if (!ft8PeriodEl) return;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const remaining = FT8_PERIOD_SECONDS - (nowSec % FT8_PERIOD_SECONDS);
|
||||||
|
ft8PeriodEl.textContent = `Next slot ${String(remaining).padStart(2, "0")}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFt8PeriodTimer();
|
||||||
|
setInterval(updateFt8PeriodTimer, 500);
|
||||||
|
|
||||||
|
function renderFt8Row(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const rawMessage = (msg.message || "").toString();
|
||||||
|
row.dataset.message = rawMessage.toUpperCase();
|
||||||
|
row.dataset.decoder = "ft8";
|
||||||
|
row.dataset.storedFreqHz = Number.isFinite(msg.freq_hz) ? String(msg.freq_hz) : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||||
|
const freq = Number.isFinite(displayFreqHz) ? displayFreqHz.toFixed(0) : "--";
|
||||||
|
const renderedMessage = renderFt8Message(rawMessage);
|
||||||
|
row.innerHTML = `<span class="ft8-time">${fmtTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderedMessage}</span>`;
|
||||||
|
applyFt8FilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFt8History() {
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
if (!ft8MessagesEl) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
const limit = Math.min(ft8MessageHistory.length, FT8_MAX_DOM_ROWS);
|
||||||
|
for (let i = 0; i < limit; i += 1) {
|
||||||
|
fragment.appendChild(renderFt8Row(ft8MessageHistory[i]));
|
||||||
|
}
|
||||||
|
ft8MessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFt8Message(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
ft8MessageHistory.unshift(msg);
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
ft8BarActiveDecoder = "ft8";
|
||||||
|
scheduleFt8BarUpdate();
|
||||||
|
scheduleFt8HistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerFt8Message(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const locatorDetails = ft8ExtractLocatorDetails(raw);
|
||||||
|
const grids = locatorDetails.length > 0
|
||||||
|
? locatorDetails.map((detail) => detail.grid)
|
||||||
|
: ft8ExtractAllGrids(raw);
|
||||||
|
const station = ft8ExtractLikelyCallsign(raw);
|
||||||
|
const rfHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
locatorDetails,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: Number.isFinite(rfHz) ? rfHz : msg.freq_hz,
|
||||||
|
message: msg.message,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerFt8Batch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
ft8Status.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerFt8Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz,
|
||||||
|
locator_details: next.locatorDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
ft8MessageHistory = normalized.concat(ft8MessageHistory);
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
ft8BarActiveDecoder = "ft8";
|
||||||
|
scheduleFt8BarUpdate();
|
||||||
|
scheduleFt8HistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreFt8History = function(messages) {
|
||||||
|
window.onServerFt8Batch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneFt8HistoryView = function() {
|
||||||
|
pruneFt8MessageHistory();
|
||||||
|
updateFt8Bar();
|
||||||
|
renderFt8History();
|
||||||
|
};
|
||||||
|
|
||||||
|
function ft8BarRfText(msg) {
|
||||||
|
const displayFreqHz = normalizeFt8DisplayFreqHz(msg.freq_hz);
|
||||||
|
if (!Number.isFinite(displayFreqHz)) return null;
|
||||||
|
return `${displayFreqHz.toFixed(0)} Hz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFt8BarFrames() {
|
||||||
|
const cutoffMs = Date.now() - FT8_BAR_WINDOW_MS;
|
||||||
|
const messages = ft8MessageHistory.filter((msg) => Number(msg.ts_ms) >= cutoffMs).slice(0, 8);
|
||||||
|
const newestTsMs = messages.reduce((latest, msg) => Math.max(latest, Number(msg.ts_ms) || 0), 0);
|
||||||
|
if (messages.length === 0) {
|
||||||
|
return { count: 0, newestTsMs: 0, html: "" };
|
||||||
|
}
|
||||||
|
let html = "";
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg.ts_ms ? `<span class="aprs-bar-time">${fmtTime(msg.ts_ms)}</span>` : "";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? `${msg.snr_db.toFixed(1)} dB` : "-- dB";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? `dt ${msg.dt_s.toFixed(2)}` : null;
|
||||||
|
const rf = ft8BarRfText(msg);
|
||||||
|
const detail = [snr, dt, rf].filter(Boolean).join(" · ");
|
||||||
|
const text = ft8EscapeHtml((msg.message || "").toString());
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="aprs-bar-call">${text}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: messages.length, newestTsMs, html };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFt8Bar() {
|
||||||
|
if (!ft8BarOverlay) return;
|
||||||
|
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
const isFt8Mode = modeUpper === "DIG" || modeUpper === "USB";
|
||||||
|
const decoder = ft8BarActiveDecoder;
|
||||||
|
const builder = ft8BarBuilders[decoder];
|
||||||
|
const label = FT8_BAR_DECODER_LABELS[decoder] || "FT8";
|
||||||
|
const result = typeof builder === "function" ? builder() : null;
|
||||||
|
const newestTsMs = Number(result?.newestTsMs) || 0;
|
||||||
|
if (!isFt8Mode || !result || result.count === 0 || newestTsMs <= (ft8BarDismissedAtMsByDecoder[decoder] || 0)) {
|
||||||
|
ft8BarOverlay.style.display = "none";
|
||||||
|
ft8BarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ft8BarOverlay.innerHTML = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">${label}</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-actions"><span class="aprs-bar-window">Last 15 minutes</span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearFt8Bar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearFt8Bar();}" aria-label="Clear ${label} overlay">Clear</span></span><button class="aprs-bar-close" type="button" onclick="window.closeFt8Bar()" aria-label="Close ${label} overlay">×</button></span></div>${result.html}`;
|
||||||
|
ft8BarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateFt8Bar = updateFt8Bar;
|
||||||
|
window.clearFt8Bar = function() {
|
||||||
|
const decoder = ft8BarActiveDecoder;
|
||||||
|
if (decoder === "ft4") {
|
||||||
|
window.resetFt4HistoryView?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (decoder === "ft2") {
|
||||||
|
window.resetFt2HistoryView?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.resetFt8HistoryView?.();
|
||||||
|
};
|
||||||
|
window.closeFt8Bar = function() {
|
||||||
|
ft8BarDismissedAtMsByDecoder[ft8BarActiveDecoder] = Date.now();
|
||||||
|
if (ft8BarOverlay) {
|
||||||
|
ft8BarOverlay.style.display = "none";
|
||||||
|
ft8BarOverlay.innerHTML = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.registerFt8FamilyBarRenderer("ft8", buildFt8BarFrames);
|
||||||
|
|
||||||
|
function renderFt8Message(message) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
const ch = message[i];
|
||||||
|
if (ft8IsAlphaNum(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (ft8IsMaidenheadGridToken(grid)) {
|
||||||
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} else {
|
||||||
|
out += ft8EscapeHtml(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
out += ft8EscapeHtml(ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8TokenizeMessage(message) {
|
||||||
|
return String(message || "")
|
||||||
|
.toUpperCase()
|
||||||
|
.split(/[^A-Z0-9/]+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8ExtractAllGrids(message) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
if (ft8IsAlphaNum(message[i])) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && ft8IsAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (ft8IsMaidenheadGridToken(grid) && !seen.has(grid)) {
|
||||||
|
seen.add(grid);
|
||||||
|
out.push(grid);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8ExtractLocatorDetails(message) {
|
||||||
|
const tokens = ft8TokenizeMessage(message);
|
||||||
|
const grids = ft8ExtractAllGrids(String(message || ""));
|
||||||
|
if (tokens.length === 0 || grids.length === 0) return [];
|
||||||
|
const firstGridIdx = tokens.findIndex((token) => ft8IsMaidenheadGridToken(token));
|
||||||
|
const limit = firstGridIdx >= 0 ? firstGridIdx : tokens.length;
|
||||||
|
const callsigns = [];
|
||||||
|
for (let i = 0; i < limit; i += 1) {
|
||||||
|
if (ft8IsLikelyCallsignToken(tokens[i])) callsigns.push(tokens[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
let source = null;
|
||||||
|
let target = null;
|
||||||
|
const head = tokens[0];
|
||||||
|
if (callsigns.length > 0) {
|
||||||
|
if (head === "CQ" || head === "DE" || head === "QRZ") {
|
||||||
|
source = callsigns[0];
|
||||||
|
} else if (callsigns.length >= 2) {
|
||||||
|
target = callsigns[0];
|
||||||
|
source = callsigns[1];
|
||||||
|
} else {
|
||||||
|
source = callsigns[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return grids.map((grid) => ({
|
||||||
|
grid,
|
||||||
|
station: source || null,
|
||||||
|
source: source || null,
|
||||||
|
target: target || null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8ExtractLikelyCallsign(message) {
|
||||||
|
const locatorDetails = ft8ExtractLocatorDetails(message);
|
||||||
|
if (locatorDetails.length > 0 && locatorDetails[0].station) {
|
||||||
|
return locatorDetails[0].station;
|
||||||
|
}
|
||||||
|
const tokens = ft8TokenizeMessage(message);
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (ft8IsLikelyCallsignToken(token)) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8IsLikelyCallsignToken(token) {
|
||||||
|
if (!token) return false;
|
||||||
|
if (token.length < 3 || token.length > 12) return false;
|
||||||
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") return false;
|
||||||
|
if (ft8IsMaidenheadGridToken(token)) return false;
|
||||||
|
return /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8IsFarewellToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8IsMaidenheadGridToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !ft8IsFarewellToken(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8EscapeHtml(input) {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ft8IsAlphaNum(ch) {
|
||||||
|
return /[A-Za-z0-9]/.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateFt8HistoryLocator(targetEl) {
|
||||||
|
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||||
|
if (!locatorEl) return false;
|
||||||
|
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
|
if (!grid) return false;
|
||||||
|
if (typeof window.navigateToMapLocator === "function") {
|
||||||
|
window.navigateToMapLocator(grid, "ft8");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFt8FilterToRow(row) {
|
||||||
|
if (!ft8FilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.message || "";
|
||||||
|
row.style.display = message.includes(ft8FilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyFt8FilterToAll() {
|
||||||
|
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => applyFt8FilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateFt8RowRf(row) {
|
||||||
|
const freqEl = row.querySelector(".ft8-freq");
|
||||||
|
if (!freqEl) return;
|
||||||
|
const storedFreqHz = row.dataset.storedFreqHz ? Number(row.dataset.storedFreqHz) : NaN;
|
||||||
|
const displayFreqHz = normalizeFt8DisplayFreqHz(storedFreqHz);
|
||||||
|
if (Number.isFinite(displayFreqHz)) {
|
||||||
|
freqEl.textContent = displayFreqHz.toFixed(0);
|
||||||
|
} else {
|
||||||
|
freqEl.textContent = "--";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.updateFt8RfDisplay = function() {
|
||||||
|
const rows = ft8MessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => updateFt8RowRf(row));
|
||||||
|
updateFt8Bar();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetFt8HistoryView = function() {
|
||||||
|
ft8MessagesEl.innerHTML = "";
|
||||||
|
ft8MessageHistory = [];
|
||||||
|
updateFt8Bar();
|
||||||
|
renderFt8History();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ft8");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (ft8FilterInput) {
|
||||||
|
ft8FilterInput.addEventListener("input", () => {
|
||||||
|
ft8FilterText = ft8FilterInput.value.trim().toUpperCase();
|
||||||
|
renderFt8History();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ft8MessagesEl) {
|
||||||
|
ft8MessagesEl.addEventListener("click", (event) => {
|
||||||
|
if (!activateFt8HistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
ft8MessagesEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
if (!activateFt8HistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ft8DecodeToggleBtn = document.getElementById("ft8-decode-toggle-btn");
|
||||||
|
ft8DecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(ft8DecodeToggleBtn);
|
||||||
|
await postPath("/toggle_ft8_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT8 toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-ft8-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear FT8 history?", message: "All stored FT8 decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_ft8_decode");
|
||||||
|
window.resetFt8HistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("FT8 history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Server-side FT8 decode handler ---
|
||||||
|
window.onServerFt8 = function(msg) {
|
||||||
|
ft8Status.textContent = "Receiving";
|
||||||
|
const next = normalizeServerFt8Message(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "ft8", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz,
|
||||||
|
locator_details: next.locatorDetails,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addFt8Message(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- HF APRS Decoder Plugin (server-side decode, 300 baud) ---
|
||||||
|
const hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
|
const hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
|
const hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
|
const hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||||
|
const hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||||
|
const hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||||
|
const hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||||
|
const hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||||
|
const hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||||
|
let hfAprsFilterText = "";
|
||||||
|
let hfAprsPacketHistory = [];
|
||||||
|
let hfAprsOnlyPos = false;
|
||||||
|
let hfAprsHideCrc = false;
|
||||||
|
let hfAprsCollapseDup = false;
|
||||||
|
let hfAprsTypeFilter = "all";
|
||||||
|
|
||||||
|
function currentHfAprsHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneHfAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||||
|
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => Number(pkt?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleHfAprsHistoryRender() {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob("hf-aprs-history", () => renderHfAprsHistory());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsPacketCategory(pkt) {
|
||||||
|
const type = String(pkt.type || "").toLowerCase();
|
||||||
|
const info = String(pkt.info || "").toLowerCase();
|
||||||
|
if (pkt.lat != null && pkt.lon != null || type.includes("position")) return "position";
|
||||||
|
if (type.includes("message") || info.startsWith(":")) return "message";
|
||||||
|
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
||||||
|
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsCategoryLabel(category) {
|
||||||
|
switch (category) {
|
||||||
|
case "position": return "Position";
|
||||||
|
case "message": return "Message";
|
||||||
|
case "weather": return "Weather";
|
||||||
|
case "telemetry": return "Telemetry";
|
||||||
|
default: return "Other";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsDistanceText(pkt) {
|
||||||
|
if (serverLat == null || serverLon == null || pkt.lat == null || pkt.lon == null) return "";
|
||||||
|
const distKm = haversineKm(serverLat, serverLon, pkt.lat, pkt.lon);
|
||||||
|
if (!Number.isFinite(distKm)) return "";
|
||||||
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsPacketSignature(pkt) {
|
||||||
|
return [
|
||||||
|
pkt.srcCall || "",
|
||||||
|
pkt.destCall || "",
|
||||||
|
pkt.path || "",
|
||||||
|
pkt.info || "",
|
||||||
|
pkt.type || "",
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsHexBytes(bytes) {
|
||||||
|
if (!Array.isArray(bytes) || bytes.length === 0) return "--";
|
||||||
|
return bytes.map((b) => Number(b).toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsFilterMatch(pkt) {
|
||||||
|
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (hfAprsTypeFilter !== "all" && hfAprsPacketCategory(pkt) !== hfAprsTypeFilter) return false;
|
||||||
|
if (!hfAprsFilterText) return true;
|
||||||
|
const haystack = [
|
||||||
|
pkt.srcCall,
|
||||||
|
pkt.destCall,
|
||||||
|
pkt.path,
|
||||||
|
pkt.info,
|
||||||
|
pkt.type,
|
||||||
|
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
||||||
|
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
||||||
|
hfAprsPacketCategory(pkt),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
return haystack.includes(hfAprsFilterText);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hfAprsVisiblePackets() {
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
|
return packets.filter(hfAprsFilterMatch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseHfAprsDuplicates(packets) {
|
||||||
|
const seen = new Set();
|
||||||
|
const out = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const key = hfAprsPacketSignature(pkt);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(pkt);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHfAprsSummary() {
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
if (hfAprsTotalCountEl) {
|
||||||
|
hfAprsTotalCountEl.textContent = `${hfAprsPacketHistory.length} total`;
|
||||||
|
}
|
||||||
|
if (hfAprsVisibleCountEl) {
|
||||||
|
hfAprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
|
}
|
||||||
|
if (hfAprsLatestSeenEl) {
|
||||||
|
const latest = hfAprsPacketHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
hfAprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
hfAprsLatestSeenEl.textContent = `${latest.srcCall} ${hfAprsAgeText(latest._tsMs)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHfAprsChipState() {
|
||||||
|
document.querySelectorAll("[id^='hf-aprs-type-']").forEach((btn) => {
|
||||||
|
btn.classList.toggle("active", btn.id === `hf-aprs-type-${hfAprsTypeFilter}`);
|
||||||
|
});
|
||||||
|
hfAprsOnlyPosBtn?.classList.toggle("active", hfAprsOnlyPos);
|
||||||
|
hfAprsHideCrcBtn?.classList.toggle("active", hfAprsHideCrc);
|
||||||
|
hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHfAprsInfo(pkt) {
|
||||||
|
const bytes = Array.isArray(pkt.info_bytes) ? pkt.info_bytes : null;
|
||||||
|
if (bytes && bytes.length > 0) {
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
const b = bytes[i];
|
||||||
|
if (b >= 0x20 && b <= 0x7e) {
|
||||||
|
const ch = String.fromCharCode(b);
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = b.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
const str = pkt.info || "";
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
const code = str.charCodeAt(i);
|
||||||
|
if (code >= 0x20 && code <= 0x7e) {
|
||||||
|
const ch = str[i];
|
||||||
|
if (ch === "<") out += "<";
|
||||||
|
else if (ch === ">") out += ">";
|
||||||
|
else if (ch === "&") out += "&";
|
||||||
|
else if (ch === '"') out += """;
|
||||||
|
else out += ch;
|
||||||
|
} else {
|
||||||
|
const hex = code.toString(16).toUpperCase().padStart(2, "0");
|
||||||
|
out += `<span class="aprs-byte">0x${hex}</span>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHfAprsRow(pkt, isFresh) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "aprs-packet";
|
||||||
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
|
|
||||||
|
const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
const age = hfAprsAgeText(pkt._tsMs);
|
||||||
|
const category = hfAprsPacketCategory(pkt);
|
||||||
|
const categoryLabel = hfAprsCategoryLabel(category);
|
||||||
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeMapHtml(pkt.path)}</span>` : "";
|
||||||
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
|
const hfBadge = '<span class="aprs-badge" style="background:var(--accent-alt,#f59e0b);color:#000">HF</span>';
|
||||||
|
let symbolHtml = "";
|
||||||
|
if (pkt.symbolTable && pkt.symbolCode) {
|
||||||
|
const sheet = pkt.symbolTable === "/" ? 0 : 1;
|
||||||
|
const code = pkt.symbolCode.charCodeAt(0) - 33;
|
||||||
|
const col = code % 16;
|
||||||
|
const row2 = Math.floor(code / 16);
|
||||||
|
const bgX = -(col * 24);
|
||||||
|
const bgY = -(row2 * 24);
|
||||||
|
symbolHtml = `<span class="aprs-symbol" style="background-image:url('https://raw.githubusercontent.com/hessu/aprs-symbols/master/png/aprs-symbols-24-${sheet}.png');background-position:${bgX}px ${bgY}px"></span>`;
|
||||||
|
}
|
||||||
|
const posLink = pkt.lat != null && pkt.lon != null
|
||||||
|
? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${pkt.lat},${pkt.lon}">${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}</a>`
|
||||||
|
: "";
|
||||||
|
const distance = hfAprsDistanceText(pkt);
|
||||||
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
|
|
||||||
|
row.innerHTML =
|
||||||
|
`<div class="aprs-row-head">` +
|
||||||
|
`<span class="aprs-time">${ts}</span>` +
|
||||||
|
hfBadge +
|
||||||
|
symbolHtml +
|
||||||
|
`<span class="aprs-call">${escapeMapHtml(pkt.srcCall)}</span>` +
|
||||||
|
`<span>>${escapeMapHtml(pkt.destCall || "")}</span>` +
|
||||||
|
`<span class="${categoryClass}">${escapeMapHtml(categoryLabel)}</span>` +
|
||||||
|
pathBadge +
|
||||||
|
crcBadge +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-meta">` +
|
||||||
|
`<span class="aprs-meta-text">${escapeMapHtml(age)}</span>` +
|
||||||
|
(distance ? `<span class="aprs-meta-text">${escapeMapHtml(distance)}</span>` : "") +
|
||||||
|
`<span class="aprs-meta-text">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-detail">` +
|
||||||
|
`<span title="${escapeMapHtml(pkt.type || "")}">${renderHfAprsInfo(pkt)}</span>` +
|
||||||
|
(posLink ? `<span>${posLink}</span>` : "") +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="aprs-row-actions">` +
|
||||||
|
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${pkt.lat},${pkt.lon}">Map</button>` : "") +
|
||||||
|
(pkt.lat != null && pkt.lon != null ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${pkt.lat},${pkt.lon}">Copy Coords</button>` : "") +
|
||||||
|
`<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a>` +
|
||||||
|
`</div>` +
|
||||||
|
`<details class="aprs-details">` +
|
||||||
|
`<summary>Details</summary>` +
|
||||||
|
`<div class="aprs-details-grid">` +
|
||||||
|
`<span class="aprs-detail-label">Source</span><span class="aprs-detail-value">${escapeMapHtml(pkt.srcCall || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeMapHtml(pkt.destCall || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeMapHtml(pkt.type || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeMapHtml(pkt.path || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeMapHtml(age)}</span>` +
|
||||||
|
`<span class="aprs-detail-label">CRC</span><span class="aprs-detail-value">${pkt.crcOk ? "OK" : "Failed"}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Position</span><span class="aprs-detail-value">${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Info</span><span class="aprs-detail-value">${escapeMapHtml(pkt.info || "--")}</span>` +
|
||||||
|
`<span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeMapHtml(hfAprsHexBytes(pkt.info_bytes))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`</details>`;
|
||||||
|
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = String(el.dataset.aprsMap || "");
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (window.navigateToAprsMap && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
window.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", async () => {
|
||||||
|
const raw = String(copyBtn.dataset.aprsCopy || "");
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(raw);
|
||||||
|
showHint("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch (_e) {
|
||||||
|
showHint("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderHfAprsHistory() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
if (!hfAprsPacketsEl) {
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < visible.length; i++) {
|
||||||
|
fragment.appendChild(renderHfAprsRow(visible[i], i === 0));
|
||||||
|
}
|
||||||
|
hfAprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
}
|
||||||
|
|
||||||
|
window.resetHfAprsHistoryView = function() {
|
||||||
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
|
hfAprsPacketHistory = [];
|
||||||
|
renderHfAprsHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneHfAprsHistoryView = function() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
renderHfAprsHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
function addHfAprsPacket(pkt) {
|
||||||
|
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
||||||
|
pkt._tsMs = tsMs;
|
||||||
|
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
|
||||||
|
hfAprsPacketHistory.unshift(pkt);
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
|
||||||
|
scheduleHfAprsHistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerHfAprsPacket(pkt) {
|
||||||
|
return {
|
||||||
|
rig_id: pkt.rig_id || null,
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
srcCall: pkt.src_call,
|
||||||
|
destCall: pkt.dest_call,
|
||||||
|
path: pkt.path,
|
||||||
|
info: pkt.info,
|
||||||
|
info_bytes: pkt.info_bytes,
|
||||||
|
type: pkt.packet_type,
|
||||||
|
crcOk: pkt.crc_ok,
|
||||||
|
ts_ms: pkt.ts_ms,
|
||||||
|
lat: pkt.lat,
|
||||||
|
lon: pkt.lon,
|
||||||
|
symbolTable: pkt.symbol_table,
|
||||||
|
symbolCode: pkt.symbol_code,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerHfAprsBatch = function(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const pkt of packets) {
|
||||||
|
const next = normalizeServerHfAprsPacket(pkt);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
hfAprsPacketHistory = normalized.concat(hfAprsPacketHistory);
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
scheduleHfAprsHistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreHfAprsHistory = function(packets) {
|
||||||
|
window.onServerHfAprsBatch(packets);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||||
|
hfAprsDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||||
|
await postPath("/toggle_hf_aprs_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_hf_aprs_decode");
|
||||||
|
window.resetHfAprsHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hfAprsOnlyPosBtn) {
|
||||||
|
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
||||||
|
hfAprsOnlyPos = !hfAprsOnlyPos;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hfAprsHideCrcBtn) {
|
||||||
|
hfAprsHideCrcBtn.addEventListener("click", () => {
|
||||||
|
hfAprsHideCrc = !hfAprsHideCrc;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hfAprsCollapseDupBtn) {
|
||||||
|
hfAprsCollapseDupBtn.addEventListener("click", () => {
|
||||||
|
hfAprsCollapseDup = !hfAprsCollapseDup;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
["all", "position", "message", "weather", "telemetry", "other"].forEach((type) => {
|
||||||
|
const btn = document.getElementById(`hf-aprs-type-${type}`);
|
||||||
|
if (!btn) return;
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
hfAprsTypeFilter = type;
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (hfAprsFilterInput) {
|
||||||
|
hfAprsFilterInput.addEventListener("input", () => {
|
||||||
|
hfAprsFilterText = hfAprsFilterInput.value.trim().toUpperCase();
|
||||||
|
renderHfAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Server-side HF APRS decode handler ---
|
||||||
|
window.onServerHfAprs = function(pkt) {
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
|
};
|
||||||
|
|
||||||
|
renderHfAprsHistory();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("hf_aprs");
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// Satellite Pass Scheduling UI
|
||||||
|
// Manages the satellite overlay section within the background decoding scheduler.
|
||||||
|
// Communicates with scheduler.js via a thin window API for shared state access.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// ── DOM references (cached once) ──────────────────────────────────
|
||||||
|
const dom = {
|
||||||
|
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||||
|
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||||
|
body: document.getElementById("scheduler-sat-body"),
|
||||||
|
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||||
|
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||||
|
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||||
|
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||||
|
formTitle: document.getElementById("sch-sat-form-title"),
|
||||||
|
form: document.getElementById("sch-sat-form"),
|
||||||
|
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||||
|
preset: document.getElementById("scheduler-sat-preset"),
|
||||||
|
name: document.getElementById("scheduler-sat-name"),
|
||||||
|
norad: document.getElementById("scheduler-sat-norad"),
|
||||||
|
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||||
|
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||||
|
priority: document.getElementById("scheduler-sat-priority"),
|
||||||
|
centerHz: document.getElementById("scheduler-sat-center-hz"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Local state ───────────────────────────────────────────────────
|
||||||
|
let editIdx = null; // null = adding, number = editing
|
||||||
|
|
||||||
|
// ── Scheduler bridge ──────────────────────────────────────────────
|
||||||
|
// These accessors call into scheduler.js via window.schedulerBridge,
|
||||||
|
// which is set up by scheduler.js after it initializes.
|
||||||
|
function getBridge() {
|
||||||
|
return window.schedulerBridge || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConfig() {
|
||||||
|
const b = getBridge();
|
||||||
|
return typeof b.getConfig === "function" ? b.getConfig() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
const b = getBridge();
|
||||||
|
return typeof b.getStatus === "function" ? b.getStatus() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBookmarks() {
|
||||||
|
const b = getBridge();
|
||||||
|
return typeof b.getBookmarks === "function" ? b.getBookmarks() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty() {
|
||||||
|
var b = getBridge();
|
||||||
|
if (typeof b.markDirty === "function") b.markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
function bmName(id) {
|
||||||
|
const bm = getBookmarks().find(function (b) { return b.id === id; });
|
||||||
|
return bm ? bm.name : String(id || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFreq(hz) {
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
||||||
|
return hz + " Hz";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Satellite config helpers ──────────────────────────────────────
|
||||||
|
function getSatelliteEntries() {
|
||||||
|
var config = getConfig();
|
||||||
|
return (config && config.satellites && Array.isArray(config.satellites.entries))
|
||||||
|
? config.satellites.entries
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSatelliteConfig() {
|
||||||
|
var config = getConfig();
|
||||||
|
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites.entries) config.satellites.entries = [];
|
||||||
|
return config.satellites;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectSatelliteConfig() {
|
||||||
|
var enabled = dom.enabled ? dom.enabled.checked : false;
|
||||||
|
var pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||||
|
return {
|
||||||
|
enabled: enabled,
|
||||||
|
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||||
|
entries: getSatelliteEntries(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: section ───────────────────────────────────────────────
|
||||||
|
function renderSection() {
|
||||||
|
var config = getConfig();
|
||||||
|
var satCfg = (config && config.satellites) || {};
|
||||||
|
var enabled = !!satCfg.enabled;
|
||||||
|
|
||||||
|
if (dom.enabled) dom.enabled.checked = enabled;
|
||||||
|
if (dom.pretune) dom.pretune.value = satCfg.pretune_secs != null ? satCfg.pretune_secs : 60;
|
||||||
|
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||||
|
|
||||||
|
renderEntries();
|
||||||
|
renderPassStatus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: entries table ─────────────────────────────────────────
|
||||||
|
function renderEntries() {
|
||||||
|
if (!dom.tbody) return;
|
||||||
|
var entries = getSatelliteEntries();
|
||||||
|
var frag = document.createDocumentFragment();
|
||||||
|
|
||||||
|
entries.forEach(function (entry, idx) {
|
||||||
|
var tr = document.createElement("tr");
|
||||||
|
|
||||||
|
var tdSat = document.createElement("td");
|
||||||
|
tdSat.textContent = entry.satellite || "";
|
||||||
|
tr.appendChild(tdSat);
|
||||||
|
|
||||||
|
var tdNorad = document.createElement("td");
|
||||||
|
tdNorad.textContent = entry.norad_id || "";
|
||||||
|
tr.appendChild(tdNorad);
|
||||||
|
|
||||||
|
var tdBm = document.createElement("td");
|
||||||
|
tdBm.textContent = bmName(entry.bookmark_id);
|
||||||
|
tr.appendChild(tdBm);
|
||||||
|
|
||||||
|
var tdEl = document.createElement("td");
|
||||||
|
tdEl.textContent = (entry.min_elevation_deg != null ? entry.min_elevation_deg + "\u00B0" : "5\u00B0");
|
||||||
|
tr.appendChild(tdEl);
|
||||||
|
|
||||||
|
var tdPrio = document.createElement("td");
|
||||||
|
tdPrio.textContent = entry.priority || 0;
|
||||||
|
tr.appendChild(tdPrio);
|
||||||
|
|
||||||
|
var tdActions = document.createElement("td");
|
||||||
|
|
||||||
|
var editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "sch-write";
|
||||||
|
editBtn.type = "button";
|
||||||
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.addEventListener("click", function () {
|
||||||
|
openForm(entry, idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(editBtn);
|
||||||
|
|
||||||
|
var removeBtn = document.createElement("button");
|
||||||
|
removeBtn.className = "sch-write";
|
||||||
|
removeBtn.type = "button";
|
||||||
|
removeBtn.textContent = "Remove";
|
||||||
|
removeBtn.addEventListener("click", function () {
|
||||||
|
removeEntry(idx);
|
||||||
|
});
|
||||||
|
tdActions.appendChild(removeBtn);
|
||||||
|
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
frag.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
dom.tbody.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: pass status ───────────────────────────────────────────
|
||||||
|
function renderPassStatus() {
|
||||||
|
if (!dom.passStatus) return;
|
||||||
|
var entries = getSatelliteEntries();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
dom.passStatus.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var status = getStatus();
|
||||||
|
if (status && status.active_satellite) {
|
||||||
|
dom.passStatus.innerHTML =
|
||||||
|
'<span class="sch-sat-active-badge">PASS ACTIVE: ' +
|
||||||
|
escHtml(status.active_satellite) +
|
||||||
|
'</span>';
|
||||||
|
} else {
|
||||||
|
dom.passStatus.innerHTML =
|
||||||
|
'<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Render: bookmark dropdown ─────────────────────────────────────
|
||||||
|
function renderBookmarkSelect(selectedId) {
|
||||||
|
if (!dom.bookmark) return;
|
||||||
|
dom.bookmark.innerHTML = '<option value="">— none —</option>';
|
||||||
|
getBookmarks().forEach(function (bm) {
|
||||||
|
var opt = document.createElement("option");
|
||||||
|
opt.value = bm.id;
|
||||||
|
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||||
|
if (bm.id === selectedId) opt.selected = true;
|
||||||
|
dom.bookmark.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Entry management ──────────────────────────────────────────────
|
||||||
|
function removeEntry(idx) {
|
||||||
|
var sat = ensureSatelliteConfig();
|
||||||
|
sat.entries.splice(idx, 1);
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form: open ────────────────────────────────────────────────────
|
||||||
|
function openForm(entry, idx) {
|
||||||
|
editIdx = (idx != null) ? idx : null;
|
||||||
|
|
||||||
|
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
||||||
|
if (dom.preset) dom.preset.value = "";
|
||||||
|
if (dom.name) dom.name.value = entry ? (entry.satellite || "") : "";
|
||||||
|
if (dom.norad) dom.norad.value = entry ? (entry.norad_id || "") : "";
|
||||||
|
if (dom.minEl) dom.minEl.value = entry && entry.min_elevation_deg != null ? entry.min_elevation_deg : 5;
|
||||||
|
if (dom.priority) dom.priority.value = entry && entry.priority != null ? entry.priority : 0;
|
||||||
|
if (dom.centerHz) dom.centerHz.value = entry && entry.center_hz ? entry.center_hz : "";
|
||||||
|
|
||||||
|
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||||
|
|
||||||
|
if (dom.formWrap) {
|
||||||
|
dom.formWrap.style.display = "flex";
|
||||||
|
if (dom.name) dom.name.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form: close ───────────────────────────────────────────────────
|
||||||
|
function closeForm() {
|
||||||
|
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||||
|
editIdx = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Form: submit ──────────────────────────────────────────────────
|
||||||
|
function onFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
var satellite = dom.name ? dom.name.value.trim() : "";
|
||||||
|
var noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||||
|
var bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||||
|
|
||||||
|
if (!satellite) { window.trxUi?.notify("Enter a satellite name.", { kind: "error" }); document.getElementById("scheduler-sat-name")?.focus(); return; }
|
||||||
|
if (isNaN(noradId) || noradId <= 0) { window.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); document.getElementById("scheduler-sat-norad")?.focus(); return; }
|
||||||
|
if (!bmId) { window.trxUi?.notify("Select a bookmark.", { kind: "error" }); document.getElementById("scheduler-sat-bookmark")?.focus(); return; }
|
||||||
|
|
||||||
|
var minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||||
|
var prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||||
|
var centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||||
|
|
||||||
|
var sat = ensureSatelliteConfig();
|
||||||
|
|
||||||
|
var entryData = {
|
||||||
|
satellite: satellite,
|
||||||
|
norad_id: noradId,
|
||||||
|
bookmark_id: bmId,
|
||||||
|
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||||
|
priority: isNaN(prio) ? 0 : prio,
|
||||||
|
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||||
|
bookmark_ids: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (editIdx !== null) {
|
||||||
|
var existing = sat.entries[editIdx];
|
||||||
|
entryData.id = existing ? existing.id : ("sat_" + Date.now().toString(36));
|
||||||
|
sat.entries[editIdx] = entryData;
|
||||||
|
} else {
|
||||||
|
entryData.id = "sat_" + Date.now().toString(36);
|
||||||
|
sat.entries.push(entryData);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeForm();
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preset change handler ─────────────────────────────────────────
|
||||||
|
function onPresetChange() {
|
||||||
|
if (!dom.preset || !dom.preset.value) return;
|
||||||
|
var parts = dom.preset.value.split("|");
|
||||||
|
if (dom.name) dom.name.value = parts[0] || "";
|
||||||
|
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wire all events ───────────────────────────────────────────────
|
||||||
|
function wireEvents() {
|
||||||
|
if (dom.enabled) {
|
||||||
|
dom.enabled.addEventListener("change", function () {
|
||||||
|
if (dom.body) dom.body.style.display = dom.enabled.checked ? "" : "none";
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.pretune) {
|
||||||
|
dom.pretune.addEventListener("input", function () {
|
||||||
|
markDirty();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (dom.addBtn) dom.addBtn.addEventListener("click", function () { openForm(null, null); });
|
||||||
|
if (dom.form) dom.form.addEventListener("submit", onFormSubmit);
|
||||||
|
if (dom.formCancel) dom.formCancel.addEventListener("click", closeForm);
|
||||||
|
if (dom.preset) dom.preset.addEventListener("change", onPresetChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Public API ────────────────────────────────────────────────────
|
||||||
|
window.satScheduler = {
|
||||||
|
wireEvents: wireEvents,
|
||||||
|
renderSection: renderSection,
|
||||||
|
renderPassStatus: renderPassStatus,
|
||||||
|
collectSatelliteConfig: collectSatelliteConfig,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,546 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- SAT Plugin ---
|
||||||
|
// Live view: decoder state, latest image card
|
||||||
|
// History view: filterable table of all decoded images
|
||||||
|
// Predictions view: next 24 h passes for ham satellites
|
||||||
|
|
||||||
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
|
const satDom = {
|
||||||
|
status: document.getElementById("sat-status"),
|
||||||
|
liveView: document.getElementById("sat-live-view"),
|
||||||
|
historyView: document.getElementById("sat-history-view"),
|
||||||
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
|
historyList: document.getElementById("sat-history-list"),
|
||||||
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
|
filterInput: document.getElementById("sat-filter"),
|
||||||
|
sortSelect: document.getElementById("sat-sort"),
|
||||||
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
|
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"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
|
let satImageHistory = [];
|
||||||
|
const SAT_MAX_IMAGES = 100;
|
||||||
|
const SAT_PRED_PAGE_SIZE = 50;
|
||||||
|
let satPredShowAll = false;
|
||||||
|
let satFilterText = "";
|
||||||
|
let satActiveView = "live"; // "live" | "history" | "predictions"
|
||||||
|
let satPredData = [];
|
||||||
|
let satPredFilterText = "";
|
||||||
|
let satPredMinEl = 0;
|
||||||
|
let satPredCategory = "all";
|
||||||
|
let satPredSatCount = 0;
|
||||||
|
let satPredCountdownTimer = null;
|
||||||
|
|
||||||
|
// ── UI scheduler helper ─────────────────────────────────────────────
|
||||||
|
function scheduleSatUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
|
function switchSatView(view) {
|
||||||
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
|
satActiveView = view;
|
||||||
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "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.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
|
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;
|
||||||
|
loadSatPredictions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
window.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
|
||||||
|
satDom.viewLiveBtn?.addEventListener("click", () => switchSatView("live"));
|
||||||
|
satDom.viewHistoryBtn?.addEventListener("click", () => switchSatView("history"));
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => switchSatView("predictions"));
|
||||||
|
|
||||||
|
// ── Live view: decoder state ────────────────────────────────────────
|
||||||
|
let _lastSatLrptOn = null;
|
||||||
|
window.updateSatLiveState = function (update) {
|
||||||
|
if (!satDom.lrptState) return;
|
||||||
|
const lrptOn = !!update.lrpt_decode_enabled;
|
||||||
|
if (lrptOn !== _lastSatLrptOn) {
|
||||||
|
_lastSatLrptOn = lrptOn;
|
||||||
|
satDom.lrptState.textContent = lrptOn ? "Listening" : "Idle";
|
||||||
|
satDom.lrptState.className = "sat-live-value " + (lrptOn ? "sat-state-listening" : "sat-state-idle");
|
||||||
|
if (satDom.status) {
|
||||||
|
if (lrptOn) {
|
||||||
|
satDom.status.textContent = "Decoder active \u2014 waiting for signal";
|
||||||
|
} else {
|
||||||
|
satDom.status.textContent = "Decoder idle";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSatLatestCard() {
|
||||||
|
if (!satDom.liveLatest) return;
|
||||||
|
if (satImageHistory.length === 0) {
|
||||||
|
satDom.liveLatest.innerHTML =
|
||||||
|
'<div style="color:var(--text-muted);font-size:0.82rem;">No images decoded yet. Enable a decoder and wait for a satellite pass.</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const img = satImageHistory[0];
|
||||||
|
const decoder = img._decoder || "unknown";
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const satellite = img.satellite || "";
|
||||||
|
const channels = img.channels || img.channel_a || "";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU rows";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
|
|
||||||
|
let meta = [typeName];
|
||||||
|
if (satellite) meta.push(satellite);
|
||||||
|
if (channels) meta.push(channels);
|
||||||
|
meta.push(`${lines} ${unit}`);
|
||||||
|
meta.push(`${date} ${ts}`);
|
||||||
|
|
||||||
|
let html = `<div class="sat-latest-card">`;
|
||||||
|
html += `<div class="sat-latest-title">Latest decoded image</div>`;
|
||||||
|
html += `<div class="sat-latest-meta">${meta.join(" · ")}</div>`;
|
||||||
|
if (img.path) {
|
||||||
|
html += `<a href="${img.path}" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">Download PNG</a>`;
|
||||||
|
}
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
html += ` <button type="button" class="sat-map-btn" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="font-size:0.8rem;margin-top:0.25rem;margin-left:0.5rem;cursor:pointer;background:none;border:1px solid var(--accent);color:var(--accent);border-radius:3px;padding:1px 6px;">Show on Map</button>`;
|
||||||
|
}
|
||||||
|
html += `</div>`;
|
||||||
|
satDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
|
function getSatFilteredHistory() {
|
||||||
|
let items = satImageHistory;
|
||||||
|
|
||||||
|
const typeVal = satDom.typeFilter ? satDom.typeFilter.value : "all";
|
||||||
|
if (typeVal === "lrpt") items = items.filter((i) => i._decoder === "lrpt");
|
||||||
|
|
||||||
|
if (satFilterText) {
|
||||||
|
items = items.filter((i) => {
|
||||||
|
const haystack = [
|
||||||
|
"meteor lrpt",
|
||||||
|
i.satellite || "",
|
||||||
|
i.channels || "",
|
||||||
|
i.channel_a || "",
|
||||||
|
i.channel_b || "",
|
||||||
|
].join(" ").toUpperCase();
|
||||||
|
return haystack.includes(satFilterText);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortVal = satDom.sortSelect ? satDom.sortSelect.value : "newest";
|
||||||
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSatHistoryRow(img) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-history-row";
|
||||||
|
|
||||||
|
const decoder = img._decoder || "unknown";
|
||||||
|
const typeName = "Meteor LRPT";
|
||||||
|
const typeClass = "sat-type-lrpt";
|
||||||
|
const ts = img._ts || "--";
|
||||||
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
|
const satellite = img.satellite || "--";
|
||||||
|
const channels = img.channels || "--";
|
||||||
|
const lines = img.mcu_count || img.line_count || 0;
|
||||||
|
const unit = "MCU";
|
||||||
|
let link = img.path
|
||||||
|
? `<a href="${img.path}" target="_blank" style="color:var(--accent);">PNG</a>`
|
||||||
|
: "--";
|
||||||
|
if (img.geo_bounds) {
|
||||||
|
link += ` <a href="javascript:void(0)" onclick="window.satShowOnMap(${img.geo_bounds[0]},${img.geo_bounds[1]},${img.geo_bounds[2]},${img.geo_bounds[3]})" style="color:var(--accent);">Map</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span>${date} ${ts}</span>`,
|
||||||
|
`<span class="sat-col-type ${typeClass}">${typeName}</span>`,
|
||||||
|
`<span>${satellite}</span>`,
|
||||||
|
`<span>${channels}</span>`,
|
||||||
|
`<span>${lines} ${unit}</span>`,
|
||||||
|
`<span>${link}</span>`,
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSatHistoryTable() {
|
||||||
|
if (!satDom.historyList) return;
|
||||||
|
const items = getSatFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < items.length; i += 1) {
|
||||||
|
fragment.appendChild(renderSatHistoryRow(items[i]));
|
||||||
|
}
|
||||||
|
satDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
|
if (satDom.historyCount) {
|
||||||
|
const total = satImageHistory.length;
|
||||||
|
const shown = items.length;
|
||||||
|
satDom.historyCount.textContent =
|
||||||
|
total === 0
|
||||||
|
? "No images yet"
|
||||||
|
: shown === total
|
||||||
|
? `${total} image${total === 1 ? "" : "s"}`
|
||||||
|
: `${shown} of ${total} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add image to history ────────────────────────────────────────────
|
||||||
|
function addSatImage(img, decoder) {
|
||||||
|
const tsMs = Number.isFinite(img.ts_ms) ? Number(img.ts_ms) : Date.now();
|
||||||
|
img._tsMs = tsMs;
|
||||||
|
img._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
img._decoder = decoder;
|
||||||
|
|
||||||
|
satImageHistory.unshift(img);
|
||||||
|
if (satImageHistory.length > SAT_MAX_IMAGES) {
|
||||||
|
satImageHistory = satImageHistory.slice(0, SAT_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSatUi("sat-latest", () => renderSatLatestCard());
|
||||||
|
if (satActiveView === "history") {
|
||||||
|
scheduleSatUi("sat-history", () => renderSatHistoryTable());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server callbacks ────────────────────────────────────────────────
|
||||||
|
window.onServerLrptProgress = function (msg) {
|
||||||
|
if (satDom.status && msg.mcu_count > 0) {
|
||||||
|
satDom.status.textContent = "Receiving \u2014 " + msg.mcu_count + " MCU rows decoded";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.onServerLrptImage = function (msg) {
|
||||||
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
|
addSatImage(msg, "lrpt");
|
||||||
|
if (msg.geo_bounds && msg.path && window.addSatMapOverlay) {
|
||||||
|
window.addSatMapOverlay(msg);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetSatHistoryView = function () {
|
||||||
|
satImageHistory = [];
|
||||||
|
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
if (window.clearSatMapOverlays) window.clearSatMapOverlays();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneSatHistoryView = function () {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
renderSatLatestCard();
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Toggle buttons ──────────────────────────────────────────────────
|
||||||
|
const lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
|
lrptDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||||
|
await postPath("/toggle_lrpt_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("LRPT toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Filter / sort event listeners ───────────────────────────────────
|
||||||
|
satDom.filterInput?.addEventListener("input", () => {
|
||||||
|
satFilterText = satDom.filterInput.value.trim().toUpperCase();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
|
||||||
|
satDom.sortSelect?.addEventListener("change", () => renderSatHistoryTable());
|
||||||
|
satDom.typeFilter?.addEventListener("change", () => renderSatHistoryTable());
|
||||||
|
|
||||||
|
// ── Settings: clear history ─────────────────────────────────────────
|
||||||
|
document
|
||||||
|
.getElementById("settings-clear-sat-history")
|
||||||
|
?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_lrpt_decode");
|
||||||
|
window.resetSatHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Weather satellite history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Predictions: helpers ────────────────────────────────────────────
|
||||||
|
function azToCardinal(deg) {
|
||||||
|
const dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"];
|
||||||
|
return dirs[Math.round(deg / 45) % 8];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPredTime(ms) {
|
||||||
|
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) {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatCountdown(ms) {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
const countdownEls = container ? 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 = parseInt(el.dataset.los, 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, now) {
|
||||||
|
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) {
|
||||||
|
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() {
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
|
||||||
|
satDom.predFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satDom.predFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
satDom.predMinEl?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = parseInt(satDom.predMinEl.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
satDom.predCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satDom.predCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Predictions: main render ────────────────────────────────────────
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ── 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() {
|
||||||
|
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();
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Navigate to map centered on satellite image bounds ──────────────
|
||||||
|
window.satShowOnMap = function (south, west, north, east) {
|
||||||
|
if (typeof window.enableMapSourceFilter === "function") {
|
||||||
|
window.enableMapSourceFilter("sat");
|
||||||
|
}
|
||||||
|
const lat = (south + north) / 2;
|
||||||
|
const lon = (west + east) / 2;
|
||||||
|
if (window.navigateToAprsMap) {
|
||||||
|
window.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Initial render ──────────────────────────────────────────────────
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,565 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- Virtual Channels Plugin ---
|
||||||
|
//
|
||||||
|
// Handles the `session` and `channels` SSE events emitted by /events and
|
||||||
|
// provides the channel picker UI (SDR-only, shown when filter_controls is set).
|
||||||
|
|
||||||
|
let vchanSessionId = null;
|
||||||
|
let vchanRigId = null;
|
||||||
|
let vchanChannels = [];
|
||||||
|
let vchanActiveId = null;
|
||||||
|
let schedulerReleaseState = null;
|
||||||
|
let schedulerReleasePollTimer = null;
|
||||||
|
|
||||||
|
function vchanFmtFreq(hz) {
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + "\u202fGHz";
|
||||||
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + "\u202fMHz";
|
||||||
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + "\u202fkHz";
|
||||||
|
return hz + "\u202fHz";
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedulerReleaseSummaryText(state) {
|
||||||
|
if (!state) return "Scheduler is controlling the rig.";
|
||||||
|
const connected = Number(state.connected_sessions) || 0;
|
||||||
|
const released = Number(state.released_sessions) || 0;
|
||||||
|
if (connected === 0) return "Scheduler can control the rig.";
|
||||||
|
if (state.all_released) {
|
||||||
|
return connected === 1
|
||||||
|
? "Scheduler is controlling the rig."
|
||||||
|
: `Scheduler is controlling the rig for all ${connected} users.`;
|
||||||
|
}
|
||||||
|
if (!state.current_session_released) {
|
||||||
|
const othersReleased = Math.max(released, 0);
|
||||||
|
return othersReleased > 0
|
||||||
|
? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.`
|
||||||
|
: "You are holding control. Release it to return control to the scheduler.";
|
||||||
|
}
|
||||||
|
const blocking = Math.max(connected - released, 0);
|
||||||
|
return blocking > 0
|
||||||
|
? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.`
|
||||||
|
: "Scheduler can control the rig.";
|
||||||
|
}
|
||||||
|
|
||||||
|
function vchanRenderSchedulerRelease() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
const status = document.getElementById("scheduler-release-status");
|
||||||
|
if (!btn || !status) return;
|
||||||
|
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
||||||
|
btn.disabled = !vchanSessionId || currentReleased;
|
||||||
|
btn.classList.toggle("active", !currentReleased);
|
||||||
|
btn.textContent = "Release to Scheduler";
|
||||||
|
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanPollSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) {
|
||||||
|
schedulerReleaseState = null;
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release status failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vchanStartSchedulerReleasePolling() {
|
||||||
|
if (schedulerReleasePollTimer) {
|
||||||
|
clearInterval(schedulerReleasePollTimer);
|
||||||
|
}
|
||||||
|
schedulerReleasePollTimer = setInterval(vchanPollSchedulerRelease, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanToggleSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
const rigId = vchanRigId || (typeof lastActiveRigId !== "undefined" ? lastActiveRigId : null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: true, remote: rigId }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler release toggle failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanTakeSchedulerControl() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
if (schedulerReleaseState && !schedulerReleaseState.current_session_released) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/scheduler-control", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, released: false }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
schedulerReleaseState = await resp.json();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("scheduler control takeover failed", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
window.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||||
|
|
||||||
|
// Called by app.js when the SSE `session` event arrives.
|
||||||
|
function vchanHandleSession(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanSessionId = d.session_id || null;
|
||||||
|
vchanPollSchedulerRelease();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad session event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by app.js when the SSE `channels` event arrives.
|
||||||
|
function vchanHandleChannels(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanRigId = d.remote || null;
|
||||||
|
vchanChannels = d.channels || [];
|
||||||
|
const ids = new Set(vchanChannels.map(c => c.id));
|
||||||
|
if (!vchanActiveId && vchanChannels.length > 0 && vchanSessionId) {
|
||||||
|
// First channels event for this session — auto-subscribe to channel 0
|
||||||
|
// so we join the same tuned channel as other users on this rig.
|
||||||
|
// Use a direct subscribe (no scheduler control takeover) to avoid
|
||||||
|
// side-effects on initial connect.
|
||||||
|
vchanAutoJoinPrimary(vchanChannels[0].id);
|
||||||
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||||
|
// Active channel was evicted — fall back to channel 0 and reconnect audio.
|
||||||
|
vchanActiveId = vchanChannels.length > 0 ? vchanChannels[0].id : null;
|
||||||
|
vchanReconnectAudio();
|
||||||
|
}
|
||||||
|
vchanRender();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
if (typeof renderRdsOverlays === "function") renderRdsOverlays();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad channels event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function vchanRender() {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.innerHTML = "";
|
||||||
|
|
||||||
|
vchanChannels.forEach(ch => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.title = `Ch ${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode} · ${ch.subscribers} subscriber${ch.subscribers !== 1 ? "s" : ""}`;
|
||||||
|
if (ch.id === vchanActiveId) btn.classList.add("active");
|
||||||
|
|
||||||
|
const label = document.createElement("span");
|
||||||
|
label.className = "vchan-label";
|
||||||
|
label.textContent = `${ch.index}: ${vchanFmtFreq(ch.freq_hz)} ${ch.mode}`;
|
||||||
|
btn.appendChild(label);
|
||||||
|
|
||||||
|
if (!ch.permanent) {
|
||||||
|
const del = document.createElement("span");
|
||||||
|
del.className = "vchan-del";
|
||||||
|
del.textContent = "\u00d7";
|
||||||
|
del.title = "Delete channel";
|
||||||
|
del.addEventListener("click", e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
vchanDelete(ch.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(del);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
if (ch.id !== vchanActiveId) vchanSubscribe(ch.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
picker.appendChild(btn);
|
||||||
|
});
|
||||||
|
|
||||||
|
// "+" button — allocate a new channel at the current VFO frequency.
|
||||||
|
const addBtn = document.createElement("button");
|
||||||
|
addBtn.type = "button";
|
||||||
|
addBtn.className = "vchan-add";
|
||||||
|
addBtn.textContent = "+";
|
||||||
|
addBtn.title = "Allocate new virtual channel at current frequency";
|
||||||
|
addBtn.addEventListener("click", vchanAllocate);
|
||||||
|
picker.appendChild(addBtn);
|
||||||
|
|
||||||
|
vchanSyncAccentUI();
|
||||||
|
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||||
|
updateDocumentTitle(activeChannelRds());
|
||||||
|
}
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanAllocate() {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
|
||||||
|
// Use the last known rig frequency and mode as the starting point.
|
||||||
|
const freqHz = (typeof lastFreqHz === "number" && lastFreqHz > 0)
|
||||||
|
? lastFreqHz
|
||||||
|
: 0;
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
const mode = modeEl ? (modeEl.value || "USB") : "USB";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
const msg = await resp.text().catch(() => String(resp.status));
|
||||||
|
console.warn("vchan: allocate failed —", msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ch = await resp.json();
|
||||||
|
vchanActiveId = ch.id;
|
||||||
|
// The SSE `channels` event will trigger vchanRender(); optimistically
|
||||||
|
// mark active so the picker feels responsive even before the event arrives.
|
||||||
|
vchanRender();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: allocate error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanDelete(channelId) {
|
||||||
|
if (!vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
||||||
|
{ method: "DELETE" }
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: delete failed", resp.status);
|
||||||
|
}
|
||||||
|
// Channel list updates via SSE `channels` event.
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: delete error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight auto-join for initial connect: registers the session on
|
||||||
|
// channel 0 without taking scheduler control or reconnecting audio
|
||||||
|
// (audio isn't started yet at this point).
|
||||||
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: auto-join primary failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: auto-join error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanSubscribe(channelId) {
|
||||||
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: subscribe failed", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: subscribe error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconnect the audio WebSocket to the appropriate endpoint:
|
||||||
|
// - virtual channel: /audio?channel_id=<uuid>
|
||||||
|
// - primary channel: /audio (no param)
|
||||||
|
// Always updates _audioChannelOverride so that starting audio later
|
||||||
|
// connects to the correct channel. Only reconnects if RX audio is active.
|
||||||
|
function vchanReconnectAudio() {
|
||||||
|
// Always update the override so startRxAudio picks up the right URL,
|
||||||
|
// even when audio isn't currently running.
|
||||||
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||||
|
if (typeof _audioChannelOverride !== "undefined") {
|
||||||
|
_audioChannelOverride = ch ? ch.id : null;
|
||||||
|
}
|
||||||
|
if (typeof rxActive === "undefined" || !rxActive) return;
|
||||||
|
if (typeof stopRxAudio === "function") stopRxAudio();
|
||||||
|
// Delay so the server has time to set up the per-channel encoder.
|
||||||
|
// The server-side audio_ws handler also polls for up to 2 s, so this
|
||||||
|
// just needs to be long enough for the WS upgrade to reach the server.
|
||||||
|
setTimeout(() => {
|
||||||
|
if (typeof startRxAudio === "function") startRxAudio();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by app.js from applyCapabilities().
|
||||||
|
// Shows the channel picker only for SDR rigs.
|
||||||
|
function vchanApplyCapabilities(caps) {
|
||||||
|
const picker = document.getElementById("vchan-picker");
|
||||||
|
if (!picker) return;
|
||||||
|
picker.style.display = (caps && caps.filter_controls) ? "" : "none";
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Freq / mode interception + UI accent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Returns true when the active channel is a non-primary (virtual) channel.
|
||||||
|
function vchanIsOnVirtual() {
|
||||||
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||||
|
return vchanActiveId !== vchanChannels[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vchanActiveChannel() {
|
||||||
|
return vchanChannels.find(c => c.id === vchanActiveId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the main freq input to show the virtual channel's frequency.
|
||||||
|
function vchanUpdateFreqDisplay() {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const el = document.getElementById("freq");
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof formatFreqForStep === "function" && typeof jogUnit !== "undefined") {
|
||||||
|
el.value = formatFreqForStep(ch.freq_hz, jogUnit);
|
||||||
|
} else {
|
||||||
|
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the mode picker to the active virtual channel's mode.
|
||||||
|
// Called whenever the active channel changes or the channel list is refreshed.
|
||||||
|
function vchanSyncModeDisplay() {
|
||||||
|
const modeEl = document.getElementById("mode");
|
||||||
|
if (!modeEl) return;
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||||
|
}
|
||||||
|
// When on primary channel, app.js rig-state updates handle the picker.
|
||||||
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
|
if (typeof lastModeName !== "undefined") {
|
||||||
|
if (modeUpper === "WFM" && lastModeName !== "WFM") {
|
||||||
|
if (typeof setJogDivisor === "function") setJogDivisor(10);
|
||||||
|
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||||
|
} else if (modeUpper !== "WFM" && lastModeName === "WFM") {
|
||||||
|
if (typeof resetRdsDisplay === "function") resetRdsDisplay();
|
||||||
|
}
|
||||||
|
lastModeName = modeUpper;
|
||||||
|
}
|
||||||
|
if (typeof updateWfmControls === "function") updateWfmControls();
|
||||||
|
if (typeof updateSdrSquelchControlVisibility === "function") {
|
||||||
|
updateSdrSquelchControlVisibility();
|
||||||
|
}
|
||||||
|
if (typeof refreshRdsUi === "function") {
|
||||||
|
refreshRdsUi();
|
||||||
|
} else if (typeof positionRdsPsOverlay === "function") {
|
||||||
|
positionRdsPsOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync the BW input to the active virtual channel's bandwidth.
|
||||||
|
function vchanSyncBwDisplay() {
|
||||||
|
if (!vchanIsOnVirtual()) return;
|
||||||
|
const ch = vchanActiveChannel();
|
||||||
|
if (!ch) return;
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (!bwEl) return;
|
||||||
|
// bandwidth_hz == 0 means mode-default; derive it from the channel mode.
|
||||||
|
let bwHz = ch.bandwidth_hz || 0;
|
||||||
|
if (bwHz === 0 && typeof mwDefaultsForMode === "function") {
|
||||||
|
bwHz = mwDefaultsForMode(ch.mode)[0] || 0;
|
||||||
|
}
|
||||||
|
if (bwHz > 0) {
|
||||||
|
bwEl.value = (bwHz / 1000).toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
if (typeof currentBandwidthHz !== "undefined") {
|
||||||
|
currentBandwidthHz = bwHz;
|
||||||
|
window.currentBandwidthHz = bwHz;
|
||||||
|
} else {
|
||||||
|
window.currentBandwidthHz = bwHz;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add / remove the vchan accent class from the freq and BW inputs.
|
||||||
|
function vchanSyncAccentUI() {
|
||||||
|
const onVirtual = vchanIsOnVirtual();
|
||||||
|
const freqEl = document.getElementById("freq");
|
||||||
|
const bwEl = document.getElementById("spectrum-bw-input");
|
||||||
|
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
||||||
|
if (onVirtual) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanSyncBwDisplay();
|
||||||
|
} else if (typeof _origRefreshFreqDisplay === "function") {
|
||||||
|
_origRefreshFreqDisplay();
|
||||||
|
}
|
||||||
|
if (typeof updateDocumentTitle === "function" && typeof activeChannelRds === "function") {
|
||||||
|
updateDocumentTitle(activeChannelRds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Saved reference to the original refreshFreqDisplay from app.js.
|
||||||
|
let _origRefreshFreqDisplay = null;
|
||||||
|
|
||||||
|
function vchanSetChannelFreq(freqHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
// Validate against current SDR capture window.
|
||||||
|
if (typeof lastSpectrumData !== "undefined" && lastSpectrumData &&
|
||||||
|
lastSpectrumData.sample_rate > 0) {
|
||||||
|
const halfSpan = Number(lastSpectrumData.sample_rate) / 2;
|
||||||
|
const center = Number(lastSpectrumData.center_hz);
|
||||||
|
if (Math.abs(freqHz - center) > halfSpan) {
|
||||||
|
if (typeof showHint === "function") {
|
||||||
|
showHint(
|
||||||
|
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||||
|
3000
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fire-and-forget: scheduler control + channel freq PUT run in background.
|
||||||
|
vchanTakeSchedulerControl();
|
||||||
|
fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) }),
|
||||||
|
}
|
||||||
|
).catch(e => console.error("vchan: set freq error", e));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set bw failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set bw error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function vchanSetChannelMode(mode) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
try {
|
||||||
|
await vchanTakeSchedulerControl();
|
||||||
|
const resp = await fetch(
|
||||||
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/mode`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mode }),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
if (!resp.ok) console.warn("vchan: set mode failed", resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: set mode error", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by app.js (applyModeFromPicker) and bookmarks.js (bmApply) before
|
||||||
|
// sending /set_mode to the server. Returns true if the change was handled
|
||||||
|
// by the virtual channel (caller should skip the server request).
|
||||||
|
window.vchanInterceptMode = async function(mode) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelMode(mode);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Called by app.js bandwidth setters before sending /set_bandwidth to the
|
||||||
|
// server. Returns true if the change was handled by the virtual channel.
|
||||||
|
window.vchanInterceptBandwidth = async function(bwHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wrap setRigFrequency (defined in app.js, loaded before this file) so that
|
||||||
|
// frequency changes are redirected to the active virtual channel instead of
|
||||||
|
// the server when on a non-primary channel.
|
||||||
|
(function() {
|
||||||
|
const _orig = window.setRigFrequency;
|
||||||
|
window.setRigFrequency = function(freqHz) {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
// Optimistic local update first, then fire-and-forget channel API.
|
||||||
|
if (typeof applyLocalTunedFrequency === "function") {
|
||||||
|
if (typeof _freqOptimisticSeq !== "undefined") {
|
||||||
|
++_freqOptimisticSeq;
|
||||||
|
_freqOptimisticHz = Math.round(freqHz);
|
||||||
|
}
|
||||||
|
applyLocalTunedFrequency(Math.round(freqHz));
|
||||||
|
}
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Scheduler control is fire-and-forget — don't block the freq change.
|
||||||
|
vchanTakeSchedulerControl();
|
||||||
|
if (typeof _orig === "function") _orig(freqHz);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
(function initSchedulerReleaseControl() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
vchanToggleSchedulerRelease();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
vchanStartSchedulerReleasePolling();
|
||||||
|
vchanRenderSchedulerRelease();
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Wrap refreshFreqDisplay so the main freq field stays in sync with the
|
||||||
|
// active virtual channel's frequency (SSE rig-state updates would otherwise
|
||||||
|
// constantly overwrite it with channel 0's freq).
|
||||||
|
(function() {
|
||||||
|
_origRefreshFreqDisplay = window.refreshFreqDisplay;
|
||||||
|
window.refreshFreqDisplay = function() {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
vchanUpdateFreqDisplay();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof _origRefreshFreqDisplay === "function") _origRefreshFreqDisplay();
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- VDES Decoder Plugin (server-side decode) ---
|
||||||
|
const vdesStatus = document.getElementById("vdes-status");
|
||||||
|
const vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
|
const vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
|
const vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
|
const vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
|
const vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
|
const vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
|
const VDES_BAR_WINDOW_MS = 15 * 60 * 1000;
|
||||||
|
let vdesFilterText = "";
|
||||||
|
let vdesMessageHistory = [];
|
||||||
|
|
||||||
|
function currentVdesHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneVdesMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||||
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVdesUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVdesHistoryRender() {
|
||||||
|
scheduleVdesUi("vdes-history", () => renderVdesHistory());
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVdesBarUpdate() {
|
||||||
|
scheduleVdesUi("vdes-bar", () => updateVdesBar());
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentVdesCenterText() {
|
||||||
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
|
const hz = raw ? Number(raw) : 0;
|
||||||
|
if (!Number.isFinite(hz) || hz <= 0) return "100 kHz centered on tuned frequency";
|
||||||
|
return `100 kHz @ ${(hz / 1_000_000).toFixed(3)} MHz`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vdesAgeText(tsMs) {
|
||||||
|
if (!Number.isFinite(tsMs)) return "just now";
|
||||||
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.round(minutes / 60);
|
||||||
|
return `${hours}h ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function vdesHexPreview(rawBytes) {
|
||||||
|
if (!Array.isArray(rawBytes) || rawBytes.length === 0) return "--";
|
||||||
|
return rawBytes
|
||||||
|
.slice(0, 20)
|
||||||
|
.map((value) => Number(value).toString(16).padStart(2, "0"))
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVdesSummary() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (vdesChannelSummaryEl) {
|
||||||
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
|
}
|
||||||
|
if (vdesFrameCountEl) {
|
||||||
|
const count = vdesMessageHistory.length;
|
||||||
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
|
}
|
||||||
|
if (vdesLatestSeenEl) {
|
||||||
|
const latest = vdesMessageHistory[0];
|
||||||
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVdesFilterToRow(row) {
|
||||||
|
if (!vdesFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = row.dataset.filterText || "";
|
||||||
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVdesFilterToAll() {
|
||||||
|
if (!vdesMessagesEl) return;
|
||||||
|
vdesMessagesEl.querySelectorAll(".vdes-message").forEach((row) => applyVdesFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVdesRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "vdes-message";
|
||||||
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
const title = msg.vessel_name || "VDES Burst";
|
||||||
|
const label = msg.callsign || "VDES";
|
||||||
|
const info = msg.destination || "";
|
||||||
|
const labelText = msg.message_label || "";
|
||||||
|
const linkText = Number.isFinite(msg.link_id) ? `LID ${msg.link_id}` : "";
|
||||||
|
const syncText = Number.isFinite(msg.sync_score) ? `Sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : "";
|
||||||
|
const phaseText = Number.isFinite(msg.phase_rotation) ? `R${Number(msg.phase_rotation)}` : "";
|
||||||
|
const fecText = msg.fec_state || "";
|
||||||
|
const srcText = Number.isFinite(msg.source_id) ? `SRC ${Number(msg.source_id)}` : "";
|
||||||
|
const dstText = Number.isFinite(msg.destination_id) ? `DST ${Number(msg.destination_id)}` : "";
|
||||||
|
const sessionText = Number.isFinite(msg.session_id) ? `S${Number(msg.session_id)}` : "";
|
||||||
|
const asmText = Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : "";
|
||||||
|
const countText = Number.isFinite(msg.data_count) ? `${Number(msg.data_count)} data bits` : "";
|
||||||
|
const ackText = Number.isFinite(msg.ack_nack_mask) ? `ACK 0x${Number(msg.ack_nack_mask).toString(16).toUpperCase().padStart(4, "0")}` : "";
|
||||||
|
const cqiText = Number.isFinite(msg.channel_quality) ? `CQ ${Number(msg.channel_quality)}` : "";
|
||||||
|
const previewText = msg.payload_preview || "";
|
||||||
|
const rawHex = vdesHexPreview(msg.raw_bytes);
|
||||||
|
row.dataset.filterText = [
|
||||||
|
title,
|
||||||
|
label,
|
||||||
|
labelText,
|
||||||
|
info,
|
||||||
|
srcText,
|
||||||
|
dstText,
|
||||||
|
sessionText,
|
||||||
|
asmText,
|
||||||
|
countText,
|
||||||
|
ackText,
|
||||||
|
cqiText,
|
||||||
|
previewText,
|
||||||
|
linkText,
|
||||||
|
syncText,
|
||||||
|
phaseText,
|
||||||
|
fecText,
|
||||||
|
rawHex,
|
||||||
|
msg.message_type,
|
||||||
|
msg.bit_len,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ")
|
||||||
|
.toUpperCase();
|
||||||
|
row.innerHTML =
|
||||||
|
`<div class="vdes-row-head">` +
|
||||||
|
`<span class="vdes-time">${ts}</span>` +
|
||||||
|
`<span class="vdes-call">${escapeMapHtml(title)}</span>` +
|
||||||
|
`<span class="vdes-badge">${escapeMapHtml(label)}</span>` +
|
||||||
|
(labelText ? `<span class="vdes-badge">${escapeMapHtml(labelText)}</span>` : "") +
|
||||||
|
(linkText ? `<span class="vdes-badge">${escapeMapHtml(linkText)}</span>` : "") +
|
||||||
|
(srcText ? `<span class="vdes-badge">${escapeMapHtml(srcText)}</span>` : "") +
|
||||||
|
(dstText ? `<span class="vdes-badge">${escapeMapHtml(dstText)}</span>` : "") +
|
||||||
|
(syncText ? `<span class="vdes-badge">${escapeMapHtml(syncText)}</span>` : "") +
|
||||||
|
(phaseText ? `<span class="vdes-badge">${escapeMapHtml(phaseText)}</span>` : "") +
|
||||||
|
`<span class="vdes-badge">T${escapeMapHtml(String(msg.message_type ?? "--"))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="vdes-row-meta">` +
|
||||||
|
`<span>${escapeMapHtml(currentVdesCenterText())}</span>` +
|
||||||
|
`<span>${escapeMapHtml(`${msg.bit_len || 0} bits`)}</span>` +
|
||||||
|
(sessionText ? `<span>${escapeMapHtml(sessionText)}</span>` : "") +
|
||||||
|
(asmText ? `<span>${escapeMapHtml(asmText)}</span>` : "") +
|
||||||
|
(countText ? `<span>${escapeMapHtml(countText)}</span>` : "") +
|
||||||
|
(ackText ? `<span>${escapeMapHtml(ackText)}</span>` : "") +
|
||||||
|
(cqiText ? `<span>${escapeMapHtml(cqiText)}</span>` : "") +
|
||||||
|
(info ? `<span>${escapeMapHtml(info)}</span>` : "") +
|
||||||
|
(fecText ? `<span>${escapeMapHtml(fecText)}</span>` : "") +
|
||||||
|
`<span>${escapeMapHtml(vdesAgeText(msg._tsMs))}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
`<div class="vdes-row-detail">` +
|
||||||
|
(previewText ? `<span>${escapeMapHtml(previewText)}</span>` : "") +
|
||||||
|
(previewText ? `<span>·</span>` : "") +
|
||||||
|
`<span class="vdes-raw">${escapeMapHtml(rawHex)}</span>` +
|
||||||
|
`</div>`;
|
||||||
|
applyVdesFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVdesBar() {
|
||||||
|
if (!vdesBarOverlay) return;
|
||||||
|
updateVdesSummary();
|
||||||
|
const isVdes = (document.getElementById("mode")?.value || "").toUpperCase() === "VDES";
|
||||||
|
const cutoffMs = Date.now() - VDES_BAR_WINDOW_MS;
|
||||||
|
const messages = vdesMessageHistory.filter((msg) => msg._tsMs >= cutoffMs).slice(0, 6);
|
||||||
|
if (!isVdes || messages.length === 0) {
|
||||||
|
vdesBarOverlay.style.display = "none";
|
||||||
|
vdesBarOverlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = '<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">VDES</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearVdesBar()" onkeydown="if(event.key===\'Enter\'||event.key===\' \'){event.preventDefault();window.clearVdesBar();}" aria-label="Clear VDES overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>';
|
||||||
|
for (const msg of messages) {
|
||||||
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
||||||
|
const label = escapeMapHtml(msg.callsign || "VDES");
|
||||||
|
const title = escapeMapHtml(msg.vessel_name || "Burst");
|
||||||
|
const detail = [
|
||||||
|
`${msg.bit_len || 0} bits`,
|
||||||
|
msg.message_label ? escapeMapHtml(msg.message_label) : null,
|
||||||
|
Number.isFinite(msg.source_id) ? `src ${Number(msg.source_id)}` : null,
|
||||||
|
Number.isFinite(msg.destination_id) ? `dst ${Number(msg.destination_id)}` : null,
|
||||||
|
Number.isFinite(msg.link_id) ? `LID ${Number(msg.link_id)}` : null,
|
||||||
|
Number.isFinite(msg.asm_identifier) ? `ASM ${Number(msg.asm_identifier)}` : null,
|
||||||
|
Number.isFinite(msg.sync_score) ? `sync ${(Number(msg.sync_score) * 100).toFixed(0)}%` : null,
|
||||||
|
Number.isFinite(msg.phase_rotation) ? `rot ${Number(msg.phase_rotation)}` : null,
|
||||||
|
msg.destination ? escapeMapHtml(msg.destination) : null,
|
||||||
|
escapeMapHtml(vdesAgeText(msg._tsMs)),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}<span class="vdes-call">${title}</span> <span class="vdes-badge">${label}</span>: ${detail}</div></div>`;
|
||||||
|
}
|
||||||
|
vdesBarOverlay.innerHTML = html;
|
||||||
|
vdesBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
window.updateVdesBar = updateVdesBar;
|
||||||
|
window.clearVdesBar = function() {
|
||||||
|
window.resetVdesHistoryView();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetVdesHistoryView = function() {
|
||||||
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
|
vdesMessageHistory = [];
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderVdesHistory() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (!vdesMessagesEl) {
|
||||||
|
updateVdesSummary();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < vdesMessageHistory.length; i += 1) {
|
||||||
|
fragment.appendChild(renderVdesRow(vdesMessageHistory[i]));
|
||||||
|
}
|
||||||
|
vdesMessagesEl.replaceChildren(fragment);
|
||||||
|
updateVdesSummary();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addVdesMessage(msg) {
|
||||||
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
vdesMessageHistory.unshift(msg);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerVdesMessage(msg) {
|
||||||
|
return {
|
||||||
|
rig_id: msg.rig_id || null,
|
||||||
|
message_type: msg.message_type,
|
||||||
|
bit_len: msg.bit_len,
|
||||||
|
raw_bytes: msg.raw_bytes,
|
||||||
|
lat: msg.lat,
|
||||||
|
lon: msg.lon,
|
||||||
|
vessel_name: msg.vessel_name,
|
||||||
|
callsign: msg.callsign,
|
||||||
|
destination: msg.destination,
|
||||||
|
message_label: msg.message_label,
|
||||||
|
session_id: msg.session_id,
|
||||||
|
source_id: msg.source_id,
|
||||||
|
destination_id: msg.destination_id,
|
||||||
|
data_count: msg.data_count,
|
||||||
|
asm_identifier: msg.asm_identifier,
|
||||||
|
ack_nack_mask: msg.ack_nack_mask,
|
||||||
|
channel_quality: msg.channel_quality,
|
||||||
|
payload_preview: msg.payload_preview,
|
||||||
|
link_id: msg.link_id,
|
||||||
|
sync_score: msg.sync_score,
|
||||||
|
sync_errors: msg.sync_errors,
|
||||||
|
phase_rotation: msg.phase_rotation,
|
||||||
|
fec_state: msg.fec_state,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerVdesBatch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
|
next._tsMs = tsMs;
|
||||||
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
});
|
||||||
|
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||||
|
window.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
normalized.push(next);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreVdesHistory = function(messages) {
|
||||||
|
window.onServerVdesBatch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_vdes_decode");
|
||||||
|
window.resetVdesHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VDES history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vdesFilterInput) {
|
||||||
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||||
|
renderVdesHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerVdes = function(msg) {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
addVdesMessage(next);
|
||||||
|
if (next.lat != null && next.lon != null && window.vdesMapAddPoint) {
|
||||||
|
window.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneVdesHistoryView = function() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
updateVdesSummary();
|
||||||
|
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("vdes");
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// wefax.js — WEFAX decoder plugin for trx-frontend-http
|
||||||
|
// Live view: decoder state, live canvas, latest image card
|
||||||
|
// History view: filterable table of all decoded images
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ── DOM references (cached once) ───────────────────────────────────
|
||||||
|
var wefaxDom = {
|
||||||
|
status: document.getElementById('wefax-status'),
|
||||||
|
liveView: document.getElementById('wefax-live-view'),
|
||||||
|
historyView: document.getElementById('wefax-history-view'),
|
||||||
|
liveContainer: document.getElementById('wefax-live-container'),
|
||||||
|
liveInfo: document.getElementById('wefax-live-info'),
|
||||||
|
liveCanvas: document.getElementById('wefax-live-canvas'),
|
||||||
|
liveLatest: document.getElementById('wefax-live-latest'),
|
||||||
|
historyList: document.getElementById('wefax-history-list'),
|
||||||
|
historyCount: document.getElementById('wefax-history-count'),
|
||||||
|
filterInput: document.getElementById('wefax-filter'),
|
||||||
|
sortSelect: document.getElementById('wefax-sort'),
|
||||||
|
toggleBtn: document.getElementById('wefax-decode-toggle-btn'),
|
||||||
|
clearBtn: document.getElementById('wefax-clear-btn'),
|
||||||
|
viewLiveBtn: document.getElementById('wefax-view-live'),
|
||||||
|
viewHistoryBtn: document.getElementById('wefax-view-history'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── State ───────────────────────────────────────────────────────────
|
||||||
|
var wefaxImageHistory = [];
|
||||||
|
var WEFAX_MAX_IMAGES = 100;
|
||||||
|
var wefaxLiveCtx = null;
|
||||||
|
var wefaxLiveLineCount = 0;
|
||||||
|
var wefaxLivePixelsPerLine = 1809;
|
||||||
|
var wefaxActiveView = 'live';
|
||||||
|
var wefaxFilterText = '';
|
||||||
|
|
||||||
|
// ── Helpers ─────────────────────────────────────────────────────────
|
||||||
|
function currentWefaxHistoryRetentionMs() {
|
||||||
|
return window.getDecodeHistoryRetentionMs ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneWefaxHistory() {
|
||||||
|
var cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||||
|
wefaxImageHistory = wefaxImageHistory.filter(function (m) { return (m._tsMs || 0) > cutoff; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWefaxUi(key, job) {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === 'function') {
|
||||||
|
window.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
job();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── View switching ──────────────────────────────────────────────────
|
||||||
|
function switchWefaxView(view) {
|
||||||
|
wefaxActiveView = view;
|
||||||
|
if (wefaxDom.liveView) wefaxDom.liveView.style.display = view === 'live' ? '' : 'none';
|
||||||
|
if (wefaxDom.historyView) wefaxDom.historyView.style.display = view === 'history' ? '' : 'none';
|
||||||
|
|
||||||
|
[wefaxDom.viewLiveBtn, wefaxDom.viewHistoryBtn].forEach(function (btn) {
|
||||||
|
if (btn) btn.classList.remove('sat-view-active');
|
||||||
|
});
|
||||||
|
if (view === 'live' && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add('sat-view-active');
|
||||||
|
if (view === 'history' && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add('sat-view-active');
|
||||||
|
|
||||||
|
if (view === 'history') renderWefaxHistoryTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener('click', function () { switchWefaxView('live'); });
|
||||||
|
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener('click', function () { switchWefaxView('history'); });
|
||||||
|
|
||||||
|
// ── Live canvas rendering ───────────────────────────────────────────
|
||||||
|
function resetLiveCanvas(pixelsPerLine) {
|
||||||
|
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
wefaxDom.liveCanvas.width = pixelsPerLine;
|
||||||
|
wefaxDom.liveCanvas.height = 800;
|
||||||
|
wefaxLiveCtx = wefaxDom.liveCanvas.getContext('2d');
|
||||||
|
wefaxLiveCtx.fillStyle = '#000';
|
||||||
|
wefaxLiveCtx.fillRect(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function paintLine(lineBytes) {
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
var y = wefaxLiveLineCount;
|
||||||
|
|
||||||
|
if (y >= wefaxDom.liveCanvas.height) {
|
||||||
|
var old = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxDom.liveCanvas.height);
|
||||||
|
wefaxDom.liveCanvas.height *= 2;
|
||||||
|
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
var w = wefaxLivePixelsPerLine;
|
||||||
|
var imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||||
|
var d = imgData.data;
|
||||||
|
for (var x = 0; x < w; x++) {
|
||||||
|
var v = x < lineBytes.length ? lineBytes[x] : 0;
|
||||||
|
var i = x * 4;
|
||||||
|
d[i] = v; d[i + 1] = v; d[i + 2] = v; d[i + 3] = 255;
|
||||||
|
}
|
||||||
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
|
wefaxLiveLineCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Live view: latest image card ────────────────────────────────────
|
||||||
|
function renderWefaxLatestCard() {
|
||||||
|
if (!wefaxDom.liveLatest) return;
|
||||||
|
if (wefaxImageHistory.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>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var img = wefaxImageHistory[0];
|
||||||
|
var ts = img._ts || '--';
|
||||||
|
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : '';
|
||||||
|
var meta = [
|
||||||
|
img.ioc + ' IOC',
|
||||||
|
img.lpm + ' LPM',
|
||||||
|
img.line_count + ' lines',
|
||||||
|
date + ' ' + ts,
|
||||||
|
].join(' \u00b7 ');
|
||||||
|
|
||||||
|
var imgSrc = img._dataUrl
|
||||||
|
? img._dataUrl
|
||||||
|
: img.path
|
||||||
|
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||||
|
: null;
|
||||||
|
|
||||||
|
var html = '<div class="sat-latest-card">';
|
||||||
|
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||||
|
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + '</div>';
|
||||||
|
if (imgSrc) {
|
||||||
|
html += '<a href="' + imgSrc + '" target="_blank" style="font-size:0.8rem;color:var(--accent);display:inline-block;margin-top:0.25rem;">View full image</a>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── History view: table ─────────────────────────────────────────────
|
||||||
|
function getWefaxFilteredHistory() {
|
||||||
|
var items = wefaxImageHistory;
|
||||||
|
|
||||||
|
if (wefaxFilterText) {
|
||||||
|
items = items.filter(function (i) {
|
||||||
|
var haystack = [
|
||||||
|
String(i.ioc || ''),
|
||||||
|
String(i.lpm || ''),
|
||||||
|
String(i.line_count || ''),
|
||||||
|
].join(' ').toUpperCase();
|
||||||
|
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : 'newest';
|
||||||
|
if (sortVal === 'oldest') items = items.slice().reverse();
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWefaxHistoryRow(img) {
|
||||||
|
var row = document.createElement('div');
|
||||||
|
row.className = 'sat-history-row';
|
||||||
|
|
||||||
|
var ts = img._ts || '--';
|
||||||
|
var date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: 'short', day: 'numeric' }) : '';
|
||||||
|
var ioc = img.ioc || '--';
|
||||||
|
var lpm = img.lpm || '--';
|
||||||
|
var lines = img.line_count || 0;
|
||||||
|
|
||||||
|
var imgSrc = img._dataUrl
|
||||||
|
? img._dataUrl
|
||||||
|
: img.path
|
||||||
|
? '/images/' + escapeHtml(img.path.split('/').pop())
|
||||||
|
: null;
|
||||||
|
var link = imgSrc
|
||||||
|
? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>'
|
||||||
|
: '--';
|
||||||
|
|
||||||
|
row.innerHTML = [
|
||||||
|
'<span>' + escapeHtml(date + ' ' + ts) + '</span>',
|
||||||
|
'<span>' + escapeHtml(String(ioc)) + '</span>',
|
||||||
|
'<span>' + escapeHtml(String(lpm)) + '</span>',
|
||||||
|
'<span>' + lines + '</span>',
|
||||||
|
'<span>' + link + '</span>',
|
||||||
|
].join('');
|
||||||
|
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWefaxHistoryTable() {
|
||||||
|
if (!wefaxDom.historyList) return;
|
||||||
|
pruneWefaxHistory();
|
||||||
|
var items = getWefaxFilteredHistory();
|
||||||
|
var fragment = document.createDocumentFragment();
|
||||||
|
for (var i = 0; i < items.length; i++) {
|
||||||
|
fragment.appendChild(renderWefaxHistoryRow(items[i]));
|
||||||
|
}
|
||||||
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
|
||||||
|
if (wefaxDom.historyCount) {
|
||||||
|
var total = wefaxImageHistory.length;
|
||||||
|
var shown = items.length;
|
||||||
|
wefaxDom.historyCount.textContent =
|
||||||
|
total === 0
|
||||||
|
? 'No images yet'
|
||||||
|
: shown === total
|
||||||
|
? total + ' image' + (total === 1 ? '' : 's')
|
||||||
|
: shown + ' of ' + total + ' images';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add image to history ────────────────────────────────────────────
|
||||||
|
function addWefaxImage(msg) {
|
||||||
|
var tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
msg._tsMs = tsMs;
|
||||||
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Capture the live canvas as a data URI for thumbnails.
|
||||||
|
if (wefaxLiveCtx && wefaxLiveLineCount > 0) {
|
||||||
|
var trimmed = wefaxLiveCtx.getImageData(0, 0, wefaxDom.liveCanvas.width, wefaxLiveLineCount);
|
||||||
|
wefaxDom.liveCanvas.height = wefaxLiveLineCount;
|
||||||
|
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||||
|
try { msg._dataUrl = wefaxDom.liveCanvas.toDataURL('image/png'); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
wefaxImageHistory.unshift(msg);
|
||||||
|
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||||
|
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === 'history') {
|
||||||
|
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── SSE event handlers (public API) ─────────────────────────────────
|
||||||
|
window.onServerWefaxProgress = function (msg) {
|
||||||
|
// State-only update (no image data): show decoder state in status.
|
||||||
|
if (msg.state && !msg.line_data) {
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = msg.state;
|
||||||
|
// Highlight active states, dim idle/scanning.
|
||||||
|
wefaxDom.status.style.color = msg.state.indexOf('Idle') === 0 ? '' : 'var(--text-accent)';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.line_count <= 1 || !wefaxLiveCtx) {
|
||||||
|
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.line_data) {
|
||||||
|
var binary = atob(msg.line_data);
|
||||||
|
var bytes = new Uint8Array(binary.length);
|
||||||
|
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
paintLine(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wefaxDom.liveInfo) {
|
||||||
|
wefaxDom.liveInfo.textContent =
|
||||||
|
'Line ' + msg.line_count + ' \u00b7 ' + msg.ioc + ' IOC \u00b7 ' + msg.lpm + ' LPM';
|
||||||
|
}
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = 'Receiving \u2014 line ' + msg.line_count;
|
||||||
|
wefaxDom.status.style.color = 'var(--text-accent)';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.onServerWefax = function (msg) {
|
||||||
|
addWefaxImage(msg);
|
||||||
|
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = 'Complete \u2014 ' + msg.line_count + ' lines';
|
||||||
|
wefaxDom.status.style.color = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreWefaxHistory = function (messages) {
|
||||||
|
if (!messages || !messages.length) return;
|
||||||
|
for (var i = 0; i < messages.length; i++) {
|
||||||
|
var tsMs = Number.isFinite(messages[i].ts_ms) ? Number(messages[i].ts_ms) : Date.now();
|
||||||
|
messages[i]._tsMs = tsMs;
|
||||||
|
messages[i]._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||||
|
pruneWefaxHistory();
|
||||||
|
scheduleWefaxUi('wefax-latest', renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === 'history') {
|
||||||
|
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneWefaxHistoryView = function () {
|
||||||
|
pruneWefaxHistory();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.resetWefaxHistoryView = function () {
|
||||||
|
wefaxImageHistory = [];
|
||||||
|
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = '';
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = 'none';
|
||||||
|
wefaxLiveCtx = null;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = 'Idle';
|
||||||
|
wefaxDom.status.style.color = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Filter / sort handlers ──────────────────────────────────────────
|
||||||
|
if (wefaxDom.filterInput) {
|
||||||
|
wefaxDom.filterInput.addEventListener('input', function () {
|
||||||
|
wefaxFilterText = wefaxDom.filterInput.value.trim().toUpperCase();
|
||||||
|
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.sortSelect) {
|
||||||
|
wefaxDom.sortSelect.addEventListener('change', function () {
|
||||||
|
scheduleWefaxUi('wefax-history', renderWefaxHistoryTable);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle button sync ──────────────────────────────────────────────
|
||||||
|
// Sync the Enable/Disable button from the SSE state update. This is
|
||||||
|
// belt-and-suspenders alongside app.js _decoderToggles — guarantees the
|
||||||
|
// WEFAX button always reflects the server state.
|
||||||
|
window.syncWefaxToggle = function (enabled) {
|
||||||
|
if (!wefaxDom.toggleBtn) return;
|
||||||
|
wefaxDom.toggleBtn.dataset.enabled = enabled ? 'true' : 'false';
|
||||||
|
wefaxDom.toggleBtn.textContent = enabled ? 'Disable WEFAX' : 'Enable WEFAX';
|
||||||
|
wefaxDom.toggleBtn.style.borderColor = enabled ? '#00d17f' : '';
|
||||||
|
wefaxDom.toggleBtn.style.color = enabled ? '#00d17f' : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Button handlers ─────────────────────────────────────────────────
|
||||||
|
if (wefaxDom.toggleBtn) {
|
||||||
|
wefaxDom.toggleBtn.addEventListener('click', async function () {
|
||||||
|
try {
|
||||||
|
if (window.takeSchedulerControlForDecoderDisable) {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable(wefaxDom.toggleBtn);
|
||||||
|
}
|
||||||
|
await postPath('/toggle_wefax_decode');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WEFAX toggle failed', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.clearBtn) {
|
||||||
|
wefaxDom.clearBtn.addEventListener('click', async function () {
|
||||||
|
try {
|
||||||
|
await postPath('/clear_wefax_decode');
|
||||||
|
window.resetWefaxHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('WEFAX clear failed', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Initial render ──────────────────────────────────────────────────
|
||||||
|
renderWefaxLatestCard();
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// --- WSPR Decoder Plugin (server-side decode) ---
|
||||||
|
const wsprStatus = document.getElementById("wspr-status");
|
||||||
|
const wsprPeriodEl = document.getElementById("wspr-period");
|
||||||
|
const wsprMessagesEl = document.getElementById("wspr-messages");
|
||||||
|
const wsprFilterInput = document.getElementById("wspr-filter");
|
||||||
|
const WSPR_PERIOD_SECONDS = 120;
|
||||||
|
let wsprFilterText = "";
|
||||||
|
let wsprMessageHistory = [];
|
||||||
|
|
||||||
|
function currentWsprHistoryRetentionMs() {
|
||||||
|
return typeof window.getDecodeHistoryRetentionMs === "function"
|
||||||
|
? window.getDecodeHistoryRetentionMs()
|
||||||
|
: 24 * 60 * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneWsprMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||||
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg?._tsMs ?? msg?.ts_ms) >= cutoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleWsprHistoryRender() {
|
||||||
|
if (typeof window.trxScheduleUiFrameJob === "function") {
|
||||||
|
window.trxScheduleUiFrameJob("wspr-history", () => renderWsprHistory());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderWsprHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtWsprTime(tsMs) {
|
||||||
|
if (!tsMs) return "--:--:--";
|
||||||
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWsprPeriodTimer() {
|
||||||
|
if (!wsprPeriodEl) return;
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000);
|
||||||
|
const remaining = WSPR_PERIOD_SECONDS - (nowSec % WSPR_PERIOD_SECONDS);
|
||||||
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWsprPeriodTimer();
|
||||||
|
setInterval(updateWsprPeriodTimer, 500);
|
||||||
|
|
||||||
|
function renderWsprRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
row.dataset.decoder = "wspr";
|
||||||
|
const snr = Number.isFinite(msg.snr_db) ? msg.snr_db.toFixed(1) : "--";
|
||||||
|
const dt = Number.isFinite(msg.dt_s) ? msg.dt_s.toFixed(2) : "--";
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? window.ft8BaseHz : null;
|
||||||
|
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz) ? (baseHz + msg.freq_hz) : null;
|
||||||
|
const freq = Number.isFinite(rfHz) ? rfHz.toFixed(0) : "--";
|
||||||
|
const message = (msg.message || "").toString();
|
||||||
|
row.dataset.message = message.toUpperCase();
|
||||||
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr}</span><span class="ft8-dt">${dt}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
|
applyWsprFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWsprHistory() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
if (!wsprMessagesEl) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
|
fragment.appendChild(renderWsprRow(wsprMessageHistory[i]));
|
||||||
|
}
|
||||||
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addWsprMessage(msg) {
|
||||||
|
msg._tsMs = Number.isFinite(msg?.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
|
wsprMessageHistory.unshift(msg);
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
scheduleWsprHistoryRender();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeServerWsprMessage(msg) {
|
||||||
|
const raw = (msg.message || "").toString();
|
||||||
|
const grids = extractAllGrids(raw);
|
||||||
|
const station = extractLikelyCallsign(raw);
|
||||||
|
const baseHz = Number.isFinite(window.ft8BaseHz) ? Number(window.ft8BaseHz) : null;
|
||||||
|
const rfHz = Number.isFinite(msg.freq_hz) && Number.isFinite(baseHz)
|
||||||
|
? (baseHz + Number(msg.freq_hz))
|
||||||
|
: (Number.isFinite(msg.freq_hz) ? Number(msg.freq_hz) : null);
|
||||||
|
return {
|
||||||
|
raw,
|
||||||
|
grids,
|
||||||
|
station,
|
||||||
|
rfHz,
|
||||||
|
history: {
|
||||||
|
receiver: window.getDecodeRigMeta ? window.getDecodeRigMeta() : null,
|
||||||
|
ts_ms: msg.ts_ms,
|
||||||
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: msg.freq_hz,
|
||||||
|
message: raw,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onServerWsprBatch = function(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
wsprStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next.history._tsMs = Number.isFinite(next.history?.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
|
}
|
||||||
|
normalized.reverse();
|
||||||
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
scheduleWsprHistoryRender();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.restoreWsprHistory = function(messages) {
|
||||||
|
window.onServerWsprBatch(messages);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.pruneWsprHistoryView = function() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
renderWsprHistory();
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeWsprHtml(input) {
|
||||||
|
return input
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">")
|
||||||
|
.replaceAll("\"", """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWsprMessage(message) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
const ch = message[i];
|
||||||
|
if (isAlphaNum(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (isMaidenheadGridToken(grid)) {
|
||||||
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(ch);
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractAllGrids(message) {
|
||||||
|
const out = [];
|
||||||
|
const seen = new Set();
|
||||||
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
||||||
|
seen.add(token);
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractLikelyCallsign(message) {
|
||||||
|
const parts = String(message || "").toUpperCase().split(/[^A-Z0-9/]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.length < 3 || token.length > 12) continue;
|
||||||
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
||||||
|
if (isMaidenheadGridToken(token)) continue;
|
||||||
|
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFtxFarewellToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMaidenheadGridToken(token) {
|
||||||
|
const normalized = String(token || "").trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAlphaNum(ch) {
|
||||||
|
return /[A-Za-z0-9]/.test(ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateWsprHistoryLocator(targetEl) {
|
||||||
|
const locatorEl = targetEl?.closest?.(".ft8-locator[data-locator-grid]");
|
||||||
|
if (!locatorEl) return false;
|
||||||
|
const grid = String(locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
|
if (!grid) return false;
|
||||||
|
if (typeof window.navigateToMapLocator === "function") {
|
||||||
|
window.navigateToMapLocator(grid, "wspr");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWsprFilterToRow(row) {
|
||||||
|
if (!wsprFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.message || "";
|
||||||
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWsprFilterToAll() {
|
||||||
|
const rows = wsprMessagesEl.querySelectorAll(".ft8-row");
|
||||||
|
rows.forEach((row) => applyWsprFilterToRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
window.resetWsprHistoryView = function() {
|
||||||
|
wsprMessagesEl.innerHTML = "";
|
||||||
|
wsprMessageHistory = [];
|
||||||
|
renderWsprHistory();
|
||||||
|
if (window.clearMapMarkersByType) window.clearMapMarkersByType("wspr");
|
||||||
|
};
|
||||||
|
|
||||||
|
if (wsprFilterInput) {
|
||||||
|
wsprFilterInput.addEventListener("input", () => {
|
||||||
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||||
|
renderWsprHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wsprMessagesEl) {
|
||||||
|
wsprMessagesEl.addEventListener("click", (event) => {
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
wsprMessagesEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||||
|
wsprDecodeToggleBtn?.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await window.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||||
|
await postPath("/toggle_wspr_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WSPR toggle failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", async () => {
|
||||||
|
if (!await window.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await postPath("/clear_wspr_decode");
|
||||||
|
window.resetWsprHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WSPR history clear failed", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.onServerWspr = function(msg) {
|
||||||
|
wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
if (next.grids.length > 0 && window.mapAddLocator) {
|
||||||
|
window.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
freq_hz: next.rfHz,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addWsprMessage(next.history);
|
||||||
|
};
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
// Spectrum screenshot module (loaded on demand when user triggers screenshot).
|
||||||
|
// Communicates with app.js core via window.trx namespace.
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
const T = window.trx;
|
||||||
|
|
||||||
|
function isVisibleForSnapshot(el) {
|
||||||
|
if (!el) return false;
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
if (style.display === "none" || style.visibility === "hidden") return false;
|
||||||
|
const opacity = Number(style.opacity);
|
||||||
|
if (Number.isFinite(opacity) && opacity <= 0) return false;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return rect.width > 0 && rect.height > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawRoundedRectPath(ctx, x, y, w, h, r) {
|
||||||
|
const radius = Math.max(0, Math.min(r, Math.min(w, h) / 2));
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x + radius, y);
|
||||||
|
ctx.lineTo(x + w - radius, y);
|
||||||
|
ctx.quadraticCurveTo(x + w, y, x + w, y + radius);
|
||||||
|
ctx.lineTo(x + w, y + h - radius);
|
||||||
|
ctx.quadraticCurveTo(x + w, y + h, x + w - radius, y + h);
|
||||||
|
ctx.lineTo(x + radius, y + h);
|
||||||
|
ctx.quadraticCurveTo(x, y + h, x, y + h - radius);
|
||||||
|
ctx.lineTo(x, y + radius);
|
||||||
|
ctx.quadraticCurveTo(x, y, x + radius, y);
|
||||||
|
ctx.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawElementChrome(ctx, el, rootRect, maxAlpha = 1) {
|
||||||
|
if (!isVisibleForSnapshot(el)) return null;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const style = getComputedStyle(el);
|
||||||
|
const x = rect.left - rootRect.left;
|
||||||
|
const y = rect.top - rootRect.top;
|
||||||
|
const w = rect.width;
|
||||||
|
const h = rect.height;
|
||||||
|
const radius = parseFloat(style.borderTopLeftRadius) || 0;
|
||||||
|
const bg = T.cssColorToRgba(style.backgroundColor || "rgba(0,0,0,0)");
|
||||||
|
const borderWidth = Math.max(0, parseFloat(style.borderTopWidth) || 0);
|
||||||
|
const border = T.cssColorToRgba(style.borderTopColor || "rgba(0,0,0,0)");
|
||||||
|
|
||||||
|
const bgAlpha = Math.min(bg[3], maxAlpha);
|
||||||
|
if (bgAlpha > 0.01) {
|
||||||
|
drawRoundedRectPath(ctx, x, y, w, h, radius);
|
||||||
|
ctx.fillStyle = `rgba(${Math.round(bg[0])}, ${Math.round(bg[1])}, ${Math.round(bg[2])}, ${bgAlpha})`;
|
||||||
|
ctx.fill();
|
||||||
|
}
|
||||||
|
const borderAlpha = Math.min(border[3], maxAlpha);
|
||||||
|
if (borderWidth > 0 && borderAlpha > 0.01) {
|
||||||
|
drawRoundedRectPath(ctx, x + borderWidth * 0.5, y + borderWidth * 0.5, w - borderWidth, h - borderWidth, Math.max(0, radius - borderWidth * 0.5));
|
||||||
|
ctx.lineWidth = borderWidth;
|
||||||
|
ctx.strokeStyle = `rgba(${Math.round(border[0])}, ${Math.round(border[1])}, ${Math.round(border[2])}, ${borderAlpha})`;
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
return { x, y, w, h, style };
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawWrappedText(ctx, text, x, y, maxWidth, lineHeight, maxLines) {
|
||||||
|
const words = String(text || "").split(/\s+/).filter(Boolean);
|
||||||
|
if (!words.length) return;
|
||||||
|
let line = "";
|
||||||
|
let lineIdx = 0;
|
||||||
|
for (let i = 0; i < words.length; i += 1) {
|
||||||
|
const candidate = line ? `${line} ${words[i]}` : words[i];
|
||||||
|
if (ctx.measureText(candidate).width <= maxWidth || !line) {
|
||||||
|
line = candidate;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
|
lineIdx += 1;
|
||||||
|
if (lineIdx >= maxLines) return;
|
||||||
|
line = words[i];
|
||||||
|
}
|
||||||
|
if (line && lineIdx < maxLines) {
|
||||||
|
ctx.fillText(line, x, y + lineIdx * lineHeight);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawElementTextBlock(ctx, el, rootRect, fallbackText = null, maxAlpha = 1) {
|
||||||
|
const chrome = drawElementChrome(ctx, el, rootRect, maxAlpha);
|
||||||
|
if (!chrome) return;
|
||||||
|
const text = (fallbackText == null ? el.innerText : fallbackText) || "";
|
||||||
|
const clean = text.replace(/\s+\n/g, "\n").replace(/\n\s+/g, "\n").trim();
|
||||||
|
if (!clean) return;
|
||||||
|
const style = chrome.style;
|
||||||
|
const fontSize = parseFloat(style.fontSize) || 12;
|
||||||
|
const lineHeight = (parseFloat(style.lineHeight) || fontSize * 1.25);
|
||||||
|
const padX = 6;
|
||||||
|
const padY = 4;
|
||||||
|
const maxWidth = Math.max(20, chrome.w - padX * 2);
|
||||||
|
const maxLines = Math.max(1, Math.floor((chrome.h - padY * 2) / lineHeight));
|
||||||
|
ctx.fillStyle = style.color || "#ffffff";
|
||||||
|
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||||
|
ctx.textBaseline = "top";
|
||||||
|
const lines = clean.split(/\n+/);
|
||||||
|
let lineCursor = 0;
|
||||||
|
for (const line of lines) {
|
||||||
|
if (lineCursor >= maxLines) break;
|
||||||
|
drawWrappedText(
|
||||||
|
ctx,
|
||||||
|
line,
|
||||||
|
chrome.x + padX,
|
||||||
|
chrome.y + padY + lineCursor * lineHeight,
|
||||||
|
maxWidth,
|
||||||
|
lineHeight,
|
||||||
|
maxLines - lineCursor,
|
||||||
|
);
|
||||||
|
lineCursor += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawAxisLabels(ctx, axisEl, rootRect) {
|
||||||
|
if (!isVisibleForSnapshot(axisEl)) return;
|
||||||
|
for (const node of axisEl.children) {
|
||||||
|
if (!(node instanceof HTMLElement)) continue;
|
||||||
|
if (!(node.matches("span") || node.matches("button"))) continue;
|
||||||
|
if (!isVisibleForSnapshot(node)) continue;
|
||||||
|
const chrome = drawElementChrome(ctx, node, rootRect);
|
||||||
|
const text = (node.textContent || "").trim();
|
||||||
|
if (!chrome || !text) continue;
|
||||||
|
const style = chrome.style;
|
||||||
|
ctx.fillStyle = style.color || "#ffffff";
|
||||||
|
ctx.font = `${style.fontStyle || "normal"} ${style.fontWeight || "400"} ${style.fontSize || "12px"} ${style.fontFamily || "sans-serif"}`;
|
||||||
|
ctx.textBaseline = "middle";
|
||||||
|
ctx.fillText(text, chrome.x + 4, chrome.y + chrome.h / 2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSpectrumSnapshotCanvas() {
|
||||||
|
const rootEl = document.querySelector(".signal-visual-block");
|
||||||
|
const spectrumPanelEl = document.getElementById("spectrum-panel");
|
||||||
|
if (!rootEl || !isVisibleForSnapshot(rootEl) || !isVisibleForSnapshot(spectrumPanelEl)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
for (const renderer of [T.overviewGl, T.spectrumGl, T.signalOverlayGl]) {
|
||||||
|
const gl = renderer?.gl;
|
||||||
|
if (!gl) continue;
|
||||||
|
try {
|
||||||
|
if (typeof gl.flush === "function") gl.flush();
|
||||||
|
if (typeof gl.finish === "function") gl.finish();
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore transient WebGL state errors and capture the last good frame.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const rootRect = rootEl.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const out = document.createElement("canvas");
|
||||||
|
out.width = Math.max(1, Math.round(rootRect.width * dpr));
|
||||||
|
out.height = Math.max(1, Math.round(rootRect.height * dpr));
|
||||||
|
const ctx = out.getContext("2d");
|
||||||
|
if (!ctx) return null;
|
||||||
|
ctx.scale(dpr, dpr);
|
||||||
|
|
||||||
|
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim() || getComputedStyle(document.body).backgroundColor || "#000";
|
||||||
|
ctx.fillStyle = bg;
|
||||||
|
ctx.fillRect(0, 0, rootRect.width, rootRect.height);
|
||||||
|
|
||||||
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||||
|
const canvases = [T.overviewCanvas, T.spectrumCanvas, signalOverlayCanvas];
|
||||||
|
for (const canvas of canvases) {
|
||||||
|
if (!canvas || !isVisibleForSnapshot(canvas)) continue;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
ctx.drawImage(
|
||||||
|
canvas,
|
||||||
|
rect.left - rootRect.left,
|
||||||
|
rect.top - rootRect.top,
|
||||||
|
rect.width,
|
||||||
|
rect.height,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decoder overlays over the signal view.
|
||||||
|
// Cap background alpha to avoid opaque blocks (backdrop-filter can't be
|
||||||
|
// replicated on canvas, so frosted-glass overlays would otherwise obscure
|
||||||
|
// the spectrum).
|
||||||
|
const decoderOverlayIds = [
|
||||||
|
"ais-bar-overlay",
|
||||||
|
"vdes-bar-overlay",
|
||||||
|
"ft8-bar-overlay",
|
||||||
|
"aprs-bar-overlay",
|
||||||
|
"rds-ps-overlay",
|
||||||
|
];
|
||||||
|
for (const id of decoderOverlayIds) {
|
||||||
|
const overlayEl = document.getElementById(id);
|
||||||
|
if (!overlayEl || !isVisibleForSnapshot(overlayEl)) continue;
|
||||||
|
drawElementTextBlock(ctx, overlayEl, rootRect, null, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spectrum axis labels and bookmark chips (includes freq bar).
|
||||||
|
const spectrumFreqAxis = document.getElementById("spectrum-freq-axis");
|
||||||
|
const spectrumDbAxis = document.getElementById("spectrum-db-axis");
|
||||||
|
drawAxisLabels(ctx, spectrumFreqAxis, rootRect);
|
||||||
|
drawAxisLabels(ctx, spectrumDbAxis, rootRect);
|
||||||
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-axis"), rootRect);
|
||||||
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-left"), rootRect);
|
||||||
|
drawAxisLabels(ctx, document.getElementById("spectrum-bookmark-side-right"), rootRect);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clickCanvasDownload(href, fileName) {
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = href;
|
||||||
|
a.download = fileName;
|
||||||
|
a.rel = "noopener";
|
||||||
|
a.style.display = "none";
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
requestAnimationFrame(() => a.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCanvasAsPng(canvas, fileName) {
|
||||||
|
if (!canvas) return Promise.resolve(false);
|
||||||
|
if (typeof canvas.toBlob === "function") {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
try {
|
||||||
|
canvas.toBlob((blob) => {
|
||||||
|
if (!blob) {
|
||||||
|
resolve(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
clickCanvasDownload(url, fileName);
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||||
|
resolve(true);
|
||||||
|
}, "image/png");
|
||||||
|
} catch (_) {
|
||||||
|
resolve(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
clickCanvasDownload(canvas.toDataURL("image/png"), fileName);
|
||||||
|
return Promise.resolve(true);
|
||||||
|
} catch (_) {
|
||||||
|
return Promise.resolve(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSpectrumScreenshot() {
|
||||||
|
const snapshotCanvas = buildSpectrumSnapshotCanvas();
|
||||||
|
if (!snapshotCanvas) {
|
||||||
|
T.showHint("Spectrum view not ready", 1300);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
|
||||||
|
const saved = await saveCanvasAsPng(snapshotCanvas, `trx-spectrum-${stamp}.png`);
|
||||||
|
T.showHint(saved ? "Spectrum screenshot saved" : "Spectrum screenshot failed", saved ? 1500 : 1800);
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register module API
|
||||||
|
window.trx.modules.screenshot = {
|
||||||
|
captureSpectrumScreenshot,
|
||||||
|
buildSpectrumSnapshotCanvas,
|
||||||
|
saveCanvasAsPng,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
// Shared UI primitives. Keeping these outside app.js prevents navigation,
|
||||||
|
// feedback, dialogs, and layout preferences from growing separate state models.
|
||||||
|
(function initUiCore() {
|
||||||
|
const api = window.trxUi = window.trxUi || {};
|
||||||
|
|
||||||
|
function ensureLiveRegions() {
|
||||||
|
if (!document.getElementById("toast-region")) {
|
||||||
|
const region = document.createElement("div");
|
||||||
|
region.id = "toast-region";
|
||||||
|
region.className = "toast-region";
|
||||||
|
region.setAttribute("aria-live", "polite");
|
||||||
|
region.setAttribute("aria-atomic", "false");
|
||||||
|
document.body.appendChild(region);
|
||||||
|
}
|
||||||
|
if (!document.getElementById("ui-confirm-dialog")) {
|
||||||
|
const dialog = document.createElement("dialog");
|
||||||
|
dialog.id = "ui-confirm-dialog";
|
||||||
|
dialog.className = "ui-dialog";
|
||||||
|
dialog.innerHTML = `
|
||||||
|
<form method="dialog" class="ui-dialog-card">
|
||||||
|
<h2 id="ui-confirm-title">Confirm action</h2>
|
||||||
|
<p id="ui-confirm-message"></p>
|
||||||
|
<div class="ui-dialog-actions">
|
||||||
|
<button value="cancel" type="submit">Cancel</button>
|
||||||
|
<button value="confirm" type="submit" class="danger">Confirm</button>
|
||||||
|
</div>
|
||||||
|
</form>`;
|
||||||
|
document.body.appendChild(dialog);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.notify = function notify(message, options = {}) {
|
||||||
|
ensureLiveRegions();
|
||||||
|
const { kind = "info", duration = kind === "error" ? 7000 : 3200, action = null } = options;
|
||||||
|
const toast = document.createElement("div");
|
||||||
|
toast.className = `toast toast-${kind}`;
|
||||||
|
toast.setAttribute("role", kind === "error" ? "alert" : "status");
|
||||||
|
const text = document.createElement("span");
|
||||||
|
text.textContent = message;
|
||||||
|
toast.appendChild(text);
|
||||||
|
if (action && typeof action.run === "function") {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.textContent = action.label || "Retry";
|
||||||
|
button.addEventListener("click", () => { action.run(); toast.remove(); });
|
||||||
|
toast.appendChild(button);
|
||||||
|
}
|
||||||
|
document.getElementById("toast-region").appendChild(toast);
|
||||||
|
requestAnimationFrame(() => toast.classList.add("toast-visible"));
|
||||||
|
if (duration > 0) setTimeout(() => toast.remove(), duration);
|
||||||
|
return toast;
|
||||||
|
};
|
||||||
|
|
||||||
|
api.confirm = function confirmAction(options = {}) {
|
||||||
|
ensureLiveRegions();
|
||||||
|
const dialog = document.getElementById("ui-confirm-dialog");
|
||||||
|
document.getElementById("ui-confirm-title").textContent = options.title || "Confirm action";
|
||||||
|
document.getElementById("ui-confirm-message").textContent = options.message || "Continue?";
|
||||||
|
const confirmButton = dialog.querySelector('[value="confirm"]');
|
||||||
|
confirmButton.textContent = options.confirmLabel || "Confirm";
|
||||||
|
confirmButton.classList.toggle("danger", options.danger !== false);
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const finish = () => resolve(dialog.returnValue === "confirm");
|
||||||
|
dialog.addEventListener("close", finish, { once: true });
|
||||||
|
dialog.showModal();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
api.setButtonState = function setButtonState(button, options = {}) {
|
||||||
|
if (!button) return;
|
||||||
|
const { active = false, activeLabel, inactiveLabel, busy = false, disabled = false } = options;
|
||||||
|
button.classList.toggle("is-active", active);
|
||||||
|
button.classList.toggle("is-busy", busy);
|
||||||
|
button.setAttribute("aria-pressed", String(active));
|
||||||
|
button.setAttribute("aria-busy", String(busy));
|
||||||
|
button.disabled = disabled || busy;
|
||||||
|
const label = active ? activeLabel : inactiveLabel;
|
||||||
|
if (label) button.textContent = label;
|
||||||
|
};
|
||||||
|
|
||||||
|
api.prepareTabList = function prepareTabList(bar, kind = "primary") {
|
||||||
|
if (!bar) return;
|
||||||
|
if (bar._accessibleTabsPrepared) return;
|
||||||
|
bar._accessibleTabsPrepared = true;
|
||||||
|
const selector = kind === "primary" ? ".tab[data-tab]" : ".sub-tab[data-subtab]";
|
||||||
|
const buttons = Array.from(bar.querySelectorAll(selector));
|
||||||
|
bar.setAttribute("role", "tablist");
|
||||||
|
buttons.forEach((button, index) => {
|
||||||
|
button.setAttribute("role", "tab");
|
||||||
|
button.setAttribute("aria-selected", String(button.classList.contains("active")));
|
||||||
|
button.tabIndex = button.classList.contains("active") || (!buttons.some(b => b.classList.contains("active")) && index === 0) ? 0 : -1;
|
||||||
|
const key = button.dataset.tab || button.dataset.subtab;
|
||||||
|
button.setAttribute("aria-controls", `${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
|
const panel = document.getElementById(`${kind === "primary" ? "tab-" : "subtab-"}${key}`);
|
||||||
|
if (panel) {
|
||||||
|
if (!button.id) button.id = `${kind}-tab-${key}`;
|
||||||
|
panel.setAttribute("role", "tabpanel");
|
||||||
|
panel.setAttribute("aria-labelledby", button.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
bar.addEventListener("keydown", (event) => {
|
||||||
|
if (!buttons.includes(event.target)) return;
|
||||||
|
const direction = event.key === "ArrowRight" || event.key === "ArrowDown" ? 1
|
||||||
|
: event.key === "ArrowLeft" || event.key === "ArrowUp" ? -1 : 0;
|
||||||
|
if (!direction) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const next = buttons[(buttons.indexOf(event.target) + direction + buttons.length) % buttons.length];
|
||||||
|
next.focus();
|
||||||
|
next.click();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
api.syncSelectedTab = function syncSelectedTab(bar, selected) {
|
||||||
|
if (!bar) return;
|
||||||
|
bar.querySelectorAll('[role="tab"]').forEach((tab) => {
|
||||||
|
const active = tab === selected;
|
||||||
|
tab.setAttribute("aria-selected", String(active));
|
||||||
|
tab.tabIndex = active ? 0 : -1;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const layouts = {
|
||||||
|
compact: { label: "Compact", advanced: false, audio: false, scheduler: false, preferredTab: "main" },
|
||||||
|
broadcast: { label: "Broadcast", unavailable: "Broadcast requires an enumerated WFM-capable receiver", advanced: false, audio: true, scheduler: false, preferredTab: "main", capability: "broadcast" },
|
||||||
|
digital: { label: "Digital", unavailable: "Digital requires a compatible rig mode and an available decoder", advanced: false, audio: false, scheduler: false, preferredTab: "digital-modes", capability: "digital" },
|
||||||
|
full: { label: "Full controls", advanced: true, audio: true, scheduler: true, preferredTab: "main" },
|
||||||
|
};
|
||||||
|
const layoutCapabilities = { broadcast: false, digital: false };
|
||||||
|
let activeRigId = null;
|
||||||
|
|
||||||
|
function layoutStorageKey() {
|
||||||
|
return activeRigId ? `trxOperatorLayout:${activeRigId}` : "trxOperatorLayout";
|
||||||
|
}
|
||||||
|
|
||||||
|
function savedLayoutName() {
|
||||||
|
return localStorage.getItem(layoutStorageKey()) || localStorage.getItem("trxOperatorLayout") || "compact";
|
||||||
|
}
|
||||||
|
|
||||||
|
function layoutAvailable(layout) {
|
||||||
|
return !layout.capability || layoutCapabilities[layout.capability] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unavailableLayoutMessage() {
|
||||||
|
const unavailable = Object.values(layouts).filter(layout => !layoutAvailable(layout) && layout.unavailable);
|
||||||
|
return unavailable.length ? `Unavailable: ${unavailable.map(layout => layout.unavailable).join("; ")}.` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshLayoutOptions() {
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
if (!select) return;
|
||||||
|
const previous = select.value || document.body.dataset.operatorLayout || "compact";
|
||||||
|
select.replaceChildren();
|
||||||
|
Object.entries(layouts).forEach(([value, layout]) => {
|
||||||
|
if (!layoutAvailable(layout)) return;
|
||||||
|
select.add(new Option(layout.label, value));
|
||||||
|
});
|
||||||
|
const available = Array.from(select.options).some(option => option.value === previous);
|
||||||
|
select.value = available ? previous : "compact";
|
||||||
|
if (!available && previous !== "compact") api.applyLayout("compact", { persist: false });
|
||||||
|
select.title = unavailableLayoutMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
api.setLayoutCapabilities = function setLayoutCapabilities(capabilities = {}) {
|
||||||
|
Object.keys(layoutCapabilities).forEach((name) => {
|
||||||
|
if (name in capabilities) layoutCapabilities[name] = Boolean(capabilities[name]);
|
||||||
|
});
|
||||||
|
refreshLayoutOptions();
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
const saved = savedLayoutName();
|
||||||
|
if (select && Array.from(select.options).some(option => option.value === saved)) {
|
||||||
|
select.value = saved;
|
||||||
|
api.applyLayout(saved, { persist: false });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
api.setActiveRig = function setActiveRig(rigId) {
|
||||||
|
activeRigId = typeof rigId === "string" && rigId ? rigId : null;
|
||||||
|
const saved = savedLayoutName();
|
||||||
|
const select = document.getElementById("operator-layout-select");
|
||||||
|
if (select) select.value = Array.from(select.options).some(option => option.value === saved) ? saved : "compact";
|
||||||
|
api.applyLayout(select?.value || saved, { persist: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
api.applyLayout = function applyLayout(name, options = {}) {
|
||||||
|
const requestedLayout = layouts[name];
|
||||||
|
const permittedName = requestedLayout && layoutAvailable(requestedLayout) ? name : "compact";
|
||||||
|
const layout = layouts[permittedName];
|
||||||
|
document.body.dataset.operatorLayout = permittedName in layouts ? permittedName : "compact";
|
||||||
|
if (options.persist !== false) localStorage.setItem(layoutStorageKey(), document.body.dataset.operatorLayout);
|
||||||
|
const details = document.getElementById("advanced-radio-controls");
|
||||||
|
if (details) details.open = layout.advanced;
|
||||||
|
const audioDetails = document.getElementById("audio-controls");
|
||||||
|
if (audioDetails) audioDetails.open = layout.audio;
|
||||||
|
const schedulerDetails = document.getElementById("scheduler-controls");
|
||||||
|
if (schedulerDetails) schedulerDetails.open = layout.scheduler;
|
||||||
|
if (options.navigate && typeof window.navigateToTab === "function") {
|
||||||
|
window.navigateToTab(layout.preferredTab);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function installLayoutControls() {
|
||||||
|
const actions = document.querySelector(".top-bar-actions");
|
||||||
|
if (actions && !document.getElementById("operator-layout-select")) {
|
||||||
|
const label = document.createElement("label");
|
||||||
|
label.className = "operator-layout-picker";
|
||||||
|
label.innerHTML = '<span class="visually-hidden">Operator layout</span><select id="operator-layout-select" aria-label="Operator layout"></select>';
|
||||||
|
const select = label.querySelector("select");
|
||||||
|
const savedLayout = savedLayoutName();
|
||||||
|
actions.insertBefore(label, actions.firstChild);
|
||||||
|
select.value = savedLayout;
|
||||||
|
refreshLayoutOptions();
|
||||||
|
if (savedLayout !== "broadcast" && layouts[savedLayout]) select.value = savedLayout;
|
||||||
|
select.addEventListener("change", () => api.applyLayout(select.value, { navigate: true }));
|
||||||
|
api.applyLayout(select.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tray = document.querySelector(".controls-tray");
|
||||||
|
if (tray && !document.getElementById("advanced-radio-controls")) {
|
||||||
|
const details = document.createElement("details");
|
||||||
|
details.id = "advanced-radio-controls";
|
||||||
|
details.className = "advanced-radio-controls";
|
||||||
|
details.innerHTML = '<summary>Advanced radio controls</summary><div class="advanced-radio-body"></div>';
|
||||||
|
const body = details.querySelector(".advanced-radio-body");
|
||||||
|
["sdr-settings-row", "vchan-row", "tx-limit-row"].forEach((id) => {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (element) body.appendChild(element);
|
||||||
|
});
|
||||||
|
tray.appendChild(details);
|
||||||
|
api.applyLayout(savedLayoutName(), { persist: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function installMobileMore() {
|
||||||
|
const nav = document.querySelector(".tab-bar-nav");
|
||||||
|
if (!nav || document.getElementById("mobile-more-btn")) return;
|
||||||
|
const more = document.createElement("button");
|
||||||
|
more.id = "mobile-more-btn";
|
||||||
|
more.className = "tab mobile-more-btn";
|
||||||
|
more.type = "button";
|
||||||
|
more.innerHTML = '<span class="tab-more-icon" aria-hidden="true">•••</span><span class="tab-label">More</span>';
|
||||||
|
more.setAttribute("aria-haspopup", "menu");
|
||||||
|
more.setAttribute("aria-expanded", "false");
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.id = "mobile-more-menu";
|
||||||
|
menu.className = "mobile-more-menu";
|
||||||
|
menu.setAttribute("role", "menu");
|
||||||
|
more.setAttribute("aria-controls", menu.id);
|
||||||
|
const closeMore = (restoreFocus = false) => {
|
||||||
|
if (!menu.classList.contains("is-open")) return;
|
||||||
|
menu.classList.remove("is-open");
|
||||||
|
more.setAttribute("aria-expanded", "false");
|
||||||
|
if (restoreFocus) more.focus();
|
||||||
|
};
|
||||||
|
api.closeMobileOverlays = closeMore;
|
||||||
|
["statistics", "recorder", "settings", "about"].forEach((tabName) => {
|
||||||
|
const source = nav.querySelector(`[data-tab="${tabName}"]`);
|
||||||
|
if (!source) return;
|
||||||
|
const item = document.createElement("button");
|
||||||
|
item.type = "button";
|
||||||
|
item.setAttribute("role", "menuitem");
|
||||||
|
item.dataset.navigateTab = tabName;
|
||||||
|
item.textContent = source.textContent.trim();
|
||||||
|
item.addEventListener("click", () => {
|
||||||
|
if (typeof window.navigateToTab === "function") window.navigateToTab(tabName);
|
||||||
|
closeMore();
|
||||||
|
});
|
||||||
|
menu.appendChild(item);
|
||||||
|
});
|
||||||
|
more.addEventListener("click", () => {
|
||||||
|
const open = menu.classList.toggle("is-open");
|
||||||
|
more.setAttribute("aria-expanded", String(open));
|
||||||
|
if (open) menu.querySelector('[role="menuitem"]')?.focus();
|
||||||
|
});
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
if (!menu.contains(event.target) && !more.contains(event.target)) closeMore();
|
||||||
|
});
|
||||||
|
document.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key === "Escape") closeMore(true);
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => closeMore());
|
||||||
|
window.addEventListener("popstate", () => closeMore());
|
||||||
|
nav.append(more, menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDecoderPicker() {
|
||||||
|
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||||
|
if (!bar || document.getElementById("decoder-tab-select")) return;
|
||||||
|
const select = document.createElement("select");
|
||||||
|
select.id = "decoder-tab-select";
|
||||||
|
select.className = "decoder-tab-select";
|
||||||
|
select.setAttribute("aria-label", "Decoder view");
|
||||||
|
const groups = [
|
||||||
|
["Overview", ["overview"]], ["Marine & packet", ["ais", "vdes", "aprs", "hf-aprs"]],
|
||||||
|
["Weak signal", ["cw", "ft8", "ft4", "ft2", "wspr"]], ["Broadcast & images", ["rds", "sat", "wefax"]],
|
||||||
|
];
|
||||||
|
groups.forEach(([label, ids]) => {
|
||||||
|
const group = document.createElement("optgroup");
|
||||||
|
group.label = label;
|
||||||
|
ids.forEach((id) => {
|
||||||
|
const button = bar.querySelector(`[data-subtab="${id}"]`);
|
||||||
|
if (button) group.appendChild(new Option(button.textContent.trim(), id));
|
||||||
|
});
|
||||||
|
select.appendChild(group);
|
||||||
|
});
|
||||||
|
select.addEventListener("change", () => bar.querySelector(`[data-subtab="${select.value}"]`)?.click());
|
||||||
|
bar.insertAdjacentElement("afterend", select);
|
||||||
|
}
|
||||||
|
|
||||||
|
function installDecoderBadges() {
|
||||||
|
const bar = document.querySelector("#tab-digital-modes > .sub-tab-bar");
|
||||||
|
if (!bar) return;
|
||||||
|
bar.querySelectorAll(".sub-tab[data-subtab]").forEach((button) => {
|
||||||
|
const id = button.dataset.subtab;
|
||||||
|
if (id === "overview" || button.querySelector(".decoder-state-dot")) return;
|
||||||
|
const dot = document.createElement("span");
|
||||||
|
dot.className = "decoder-state-dot";
|
||||||
|
dot.setAttribute("aria-hidden", "true");
|
||||||
|
button.appendChild(dot);
|
||||||
|
const status = document.getElementById(`${id}-status`);
|
||||||
|
if (!status) return;
|
||||||
|
const sync = () => {
|
||||||
|
const value = status.textContent.toLowerCase();
|
||||||
|
const state = /receiv|decod|connected|listening/.test(value) ? "active"
|
||||||
|
: /error|fail|disconnected/.test(value) ? "error" : "idle";
|
||||||
|
dot.dataset.state = state;
|
||||||
|
button.title = `${button.childNodes[0]?.textContent?.trim() || id}: ${status.textContent.trim()}`;
|
||||||
|
};
|
||||||
|
new MutationObserver(sync).observe(status, { childList: true, characterData: true, subtree: true });
|
||||||
|
sync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
api.init = function init() {
|
||||||
|
ensureLiveRegions();
|
||||||
|
installLayoutControls();
|
||||||
|
installMobileMore();
|
||||||
|
installDecoderPicker();
|
||||||
|
installDecoderBadges();
|
||||||
|
api.prepareTabList(document.querySelector(".tab-bar-nav"), "primary");
|
||||||
|
document.querySelectorAll(".sub-tab-bar").forEach(bar => api.prepareTabList(bar, "secondary"));
|
||||||
|
window.addEventListener("unhandledrejection", (event) => {
|
||||||
|
const message = event.reason?.message || "An operation failed unexpectedly";
|
||||||
|
api.notify(message, { kind: "error" });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", api.init, { once: true });
|
||||||
|
else api.init();
|
||||||
|
})();
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
(function initTrxWebGl(global) {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const cssColorCache = new Map();
|
||||||
|
let cssColorProbe = null;
|
||||||
|
|
||||||
|
function clearCssColorCache() {
|
||||||
|
cssColorCache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureCssColorProbe() {
|
||||||
|
if (cssColorProbe) return cssColorProbe;
|
||||||
|
const el = document.createElement("span");
|
||||||
|
el.style.position = "absolute";
|
||||||
|
el.style.left = "-9999px";
|
||||||
|
el.style.top = "-9999px";
|
||||||
|
el.style.pointerEvents = "none";
|
||||||
|
el.style.opacity = "0";
|
||||||
|
document.body.appendChild(el);
|
||||||
|
cssColorProbe = el;
|
||||||
|
return cssColorProbe;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRgbString(value) {
|
||||||
|
const m = /^rgba?\(([^)]+)\)$/.exec(String(value || "").trim());
|
||||||
|
if (!m) return null;
|
||||||
|
const parts = m[1].split(",").map((p) => p.trim());
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const r = Number(parts[0]);
|
||||||
|
const g = Number(parts[1]);
|
||||||
|
const b = Number(parts[2]);
|
||||||
|
const a = parts.length > 3 ? Number(parts[3]) : 1;
|
||||||
|
if (![r, g, b, a].every(Number.isFinite)) return null;
|
||||||
|
return [
|
||||||
|
Math.max(0, Math.min(1, r / 255)),
|
||||||
|
Math.max(0, Math.min(1, g / 255)),
|
||||||
|
Math.max(0, Math.min(1, b / 255)),
|
||||||
|
Math.max(0, Math.min(1, a)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHexColor(value) {
|
||||||
|
const raw = String(value || "").trim();
|
||||||
|
if (!/^#([0-9a-f]{3,8})$/i.test(raw)) return null;
|
||||||
|
let hex = raw.slice(1);
|
||||||
|
if (hex.length === 3 || hex.length === 4) {
|
||||||
|
hex = hex.split("").map((ch) => ch + ch).join("");
|
||||||
|
}
|
||||||
|
if (!(hex.length === 6 || hex.length === 8)) return null;
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16) / 255;
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16) / 255;
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16) / 255;
|
||||||
|
const a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
|
||||||
|
return [r, g, b, a];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCssColor(value) {
|
||||||
|
const key = String(value ?? "");
|
||||||
|
if (cssColorCache.has(key)) return cssColorCache.get(key).slice();
|
||||||
|
|
||||||
|
let parsed = parseHexColor(key) || parseRgbString(key);
|
||||||
|
if (!parsed) {
|
||||||
|
const probe = ensureCssColorProbe();
|
||||||
|
probe.style.color = "";
|
||||||
|
probe.style.color = key;
|
||||||
|
const computed = getComputedStyle(probe).color;
|
||||||
|
parsed = parseRgbString(computed) || [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
cssColorCache.set(key, parsed.slice());
|
||||||
|
return parsed.slice();
|
||||||
|
}
|
||||||
|
|
||||||
|
function hslToRgba(h, s, l, a = 1) {
|
||||||
|
const hue = ((((Number(h) || 0) % 360) + 360) % 360) / 360;
|
||||||
|
const sat = Math.max(0, Math.min(1, (Number(s) || 0) / 100));
|
||||||
|
const lig = Math.max(0, Math.min(1, (Number(l) || 0) / 100));
|
||||||
|
|
||||||
|
const q = lig < 0.5 ? lig * (1 + sat) : lig + sat - lig * sat;
|
||||||
|
const p = 2 * lig - q;
|
||||||
|
const hueToRgb = (t) => {
|
||||||
|
let tt = t;
|
||||||
|
if (tt < 0) tt += 1;
|
||||||
|
if (tt > 1) tt -= 1;
|
||||||
|
if (tt < 1 / 6) return p + (q - p) * 6 * tt;
|
||||||
|
if (tt < 1 / 2) return q;
|
||||||
|
if (tt < 2 / 3) return p + (q - p) * (2 / 3 - tt) * 6;
|
||||||
|
return p;
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = sat === 0 ? lig : hueToRgb(hue + 1 / 3);
|
||||||
|
const g = sat === 0 ? lig : hueToRgb(hue);
|
||||||
|
const b = sat === 0 ? lig : hueToRgb(hue - 1 / 3);
|
||||||
|
return [r, g, b, Math.max(0, Math.min(1, Number(a)))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeColor(input, alphaMul = 1) {
|
||||||
|
let rgba;
|
||||||
|
if (Array.isArray(input)) {
|
||||||
|
const arr = input.map((v) => Number(v));
|
||||||
|
if (arr.length >= 4) {
|
||||||
|
rgba = [arr[0], arr[1], arr[2], arr[3]];
|
||||||
|
} else {
|
||||||
|
rgba = [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
} else if (typeof input === "string") {
|
||||||
|
rgba = parseCssColor(input);
|
||||||
|
} else if (input && typeof input === "object") {
|
||||||
|
rgba = [
|
||||||
|
Number(input.r) || 0,
|
||||||
|
Number(input.g) || 0,
|
||||||
|
Number(input.b) || 0,
|
||||||
|
Number(input.a ?? 1),
|
||||||
|
];
|
||||||
|
} else {
|
||||||
|
rgba = [0, 0, 0, 1];
|
||||||
|
}
|
||||||
|
const out = [
|
||||||
|
Math.max(0, Math.min(1, rgba[0])),
|
||||||
|
Math.max(0, Math.min(1, rgba[1])),
|
||||||
|
Math.max(0, Math.min(1, rgba[2])),
|
||||||
|
Math.max(0, Math.min(1, rgba[3] * alphaMul)),
|
||||||
|
];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compileShader(gl, type, source) {
|
||||||
|
const shader = gl.createShader(type);
|
||||||
|
gl.shaderSource(shader, source);
|
||||||
|
gl.compileShader(shader);
|
||||||
|
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
||||||
|
const log = gl.getShaderInfoLog(shader) || "shader compile error";
|
||||||
|
gl.deleteShader(shader);
|
||||||
|
throw new Error(log);
|
||||||
|
}
|
||||||
|
return shader;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createProgram(gl, vertexSrc, fragmentSrc) {
|
||||||
|
const vs = compileShader(gl, gl.VERTEX_SHADER, vertexSrc);
|
||||||
|
const fs = compileShader(gl, gl.FRAGMENT_SHADER, fragmentSrc);
|
||||||
|
const program = gl.createProgram();
|
||||||
|
gl.attachShader(program, vs);
|
||||||
|
gl.attachShader(program, fs);
|
||||||
|
gl.linkProgram(program);
|
||||||
|
gl.deleteShader(vs);
|
||||||
|
gl.deleteShader(fs);
|
||||||
|
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
||||||
|
const log = gl.getProgramInfoLog(program) || "program link error";
|
||||||
|
gl.deleteProgram(program);
|
||||||
|
throw new Error(log);
|
||||||
|
}
|
||||||
|
return program;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushColoredVertex(target, x, y, rgba) {
|
||||||
|
target.push(x, y, rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function segmentToQuadVertices(out, x0, y0, x1, y1, halfW, rgba) {
|
||||||
|
const dx = x1 - x0;
|
||||||
|
const dy = y1 - y0;
|
||||||
|
const len = Math.hypot(dx, dy);
|
||||||
|
if (!(len > 0.0001)) return;
|
||||||
|
const nx = (-dy / len) * halfW;
|
||||||
|
const ny = (dx / len) * halfW;
|
||||||
|
|
||||||
|
const ax = x0 - nx, ay = y0 - ny;
|
||||||
|
const bx = x0 + nx, by = y0 + ny;
|
||||||
|
const cx = x1 + nx, cy = y1 + ny;
|
||||||
|
const dx2 = x1 - nx, dy2 = y1 - ny;
|
||||||
|
|
||||||
|
pushColoredVertex(out, ax, ay, rgba);
|
||||||
|
pushColoredVertex(out, bx, by, rgba);
|
||||||
|
pushColoredVertex(out, cx, cy, rgba);
|
||||||
|
|
||||||
|
pushColoredVertex(out, ax, ay, rgba);
|
||||||
|
pushColoredVertex(out, cx, cy, rgba);
|
||||||
|
pushColoredVertex(out, dx2, dy2, rgba);
|
||||||
|
}
|
||||||
|
|
||||||
|
class TrxWebGlRenderer {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.options = { alpha: true, premultipliedAlpha: false, ...options };
|
||||||
|
this.gl =
|
||||||
|
canvas?.getContext("webgl", this.options) ||
|
||||||
|
canvas?.getContext("experimental-webgl", this.options) ||
|
||||||
|
null;
|
||||||
|
this.ready = !!this.gl;
|
||||||
|
this.textures = new Map();
|
||||||
|
// Reusable scratch buffers — avoids per-draw-call Float32Array allocation
|
||||||
|
// and lets us use bufferSubData instead of bufferData (no GPU realloc).
|
||||||
|
this._colorScratch = new Float32Array(4096 * 6); // grows as needed
|
||||||
|
this._colorGpuSize = 0; // current GPU buffer size (floats)
|
||||||
|
this._texScratch = new Float32Array(6 * 4); // fixed: 6 verts × (xy+uv)
|
||||||
|
if (!this.ready) return;
|
||||||
|
|
||||||
|
const gl = this.gl;
|
||||||
|
gl.disable(gl.DEPTH_TEST);
|
||||||
|
gl.disable(gl.CULL_FACE);
|
||||||
|
gl.enable(gl.BLEND);
|
||||||
|
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
||||||
|
|
||||||
|
const colorVertexSrc =
|
||||||
|
"attribute vec2 a_pos;\n" +
|
||||||
|
"attribute vec4 a_color;\n" +
|
||||||
|
"uniform vec2 u_resolution;\n" +
|
||||||
|
"varying vec4 v_color;\n" +
|
||||||
|
"void main() {\n" +
|
||||||
|
" vec2 zeroToOne = a_pos / u_resolution;\n" +
|
||||||
|
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
|
||||||
|
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
|
||||||
|
" v_color = a_color;\n" +
|
||||||
|
"}\n";
|
||||||
|
const colorFragmentSrc =
|
||||||
|
"precision mediump float;\n" +
|
||||||
|
"varying vec4 v_color;\n" +
|
||||||
|
"void main() {\n" +
|
||||||
|
" gl_FragColor = v_color;\n" +
|
||||||
|
"}\n";
|
||||||
|
|
||||||
|
const textureVertexSrc =
|
||||||
|
"attribute vec2 a_pos;\n" +
|
||||||
|
"attribute vec2 a_uv;\n" +
|
||||||
|
"uniform vec2 u_resolution;\n" +
|
||||||
|
"varying vec2 v_uv;\n" +
|
||||||
|
"void main() {\n" +
|
||||||
|
" vec2 zeroToOne = a_pos / u_resolution;\n" +
|
||||||
|
" vec2 clip = zeroToOne * 2.0 - 1.0;\n" +
|
||||||
|
" gl_Position = vec4(clip * vec2(1.0, -1.0), 0.0, 1.0);\n" +
|
||||||
|
" v_uv = a_uv;\n" +
|
||||||
|
"}\n";
|
||||||
|
const textureFragmentSrc =
|
||||||
|
"precision mediump float;\n" +
|
||||||
|
"varying vec2 v_uv;\n" +
|
||||||
|
"uniform sampler2D u_tex;\n" +
|
||||||
|
"uniform float u_alpha;\n" +
|
||||||
|
"void main() {\n" +
|
||||||
|
" vec4 c = texture2D(u_tex, v_uv);\n" +
|
||||||
|
" gl_FragColor = vec4(c.rgb, c.a * u_alpha);\n" +
|
||||||
|
"}\n";
|
||||||
|
|
||||||
|
this.colorProgram = createProgram(gl, colorVertexSrc, colorFragmentSrc);
|
||||||
|
this.colorBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||||
|
this._colorGpuSize = this._colorScratch.length;
|
||||||
|
this.colorLoc = {
|
||||||
|
pos: gl.getAttribLocation(this.colorProgram, "a_pos"),
|
||||||
|
color: gl.getAttribLocation(this.colorProgram, "a_color"),
|
||||||
|
resolution: gl.getUniformLocation(this.colorProgram, "u_resolution"),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.textureProgram = createProgram(gl, textureVertexSrc, textureFragmentSrc);
|
||||||
|
this.textureBuffer = gl.createBuffer();
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, this._texScratch, gl.DYNAMIC_DRAW);
|
||||||
|
this.textureLoc = {
|
||||||
|
pos: gl.getAttribLocation(this.textureProgram, "a_pos"),
|
||||||
|
uv: gl.getAttribLocation(this.textureProgram, "a_uv"),
|
||||||
|
resolution: gl.getUniformLocation(this.textureProgram, "u_resolution"),
|
||||||
|
alpha: gl.getUniformLocation(this.textureProgram, "u_alpha"),
|
||||||
|
tex: gl.getUniformLocation(this.textureProgram, "u_tex"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureSize(cssWidth, cssHeight, dpr = (window.devicePixelRatio || 1)) {
|
||||||
|
if (!this.ready) return false;
|
||||||
|
const nextW = Math.max(1, Math.round(cssWidth * dpr));
|
||||||
|
const nextH = Math.max(1, Math.round(cssHeight * dpr));
|
||||||
|
const changed = this.canvas.width !== nextW || this.canvas.height !== nextH;
|
||||||
|
if (changed) {
|
||||||
|
this.canvas.width = nextW;
|
||||||
|
this.canvas.height = nextH;
|
||||||
|
}
|
||||||
|
this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear(color) {
|
||||||
|
if (!this.ready) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
gl.clearColor(rgba[0], rgba[1], rgba[2], rgba[3]);
|
||||||
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTriangles(vertices) {
|
||||||
|
this._drawColorGeometry(vertices, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTriangleStrip(vertices) {
|
||||||
|
this._drawColorGeometry(vertices, this.gl.TRIANGLE_STRIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
_drawColorGeometry(vertices, mode) {
|
||||||
|
if (!this.ready || !vertices || vertices.length === 0) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const count = vertices.length;
|
||||||
|
|
||||||
|
// Grow scratch buffer if needed (doubles each time to amortise copies).
|
||||||
|
if (count > this._colorScratch.length) {
|
||||||
|
let newLen = this._colorScratch.length;
|
||||||
|
while (newLen < count) newLen *= 2;
|
||||||
|
this._colorScratch = new Float32Array(newLen);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy into scratch (set() is a fast typed memcpy; avoids new allocation).
|
||||||
|
this._colorScratch.set(vertices);
|
||||||
|
const view = this._colorScratch.subarray(0, count);
|
||||||
|
|
||||||
|
gl.useProgram(this.colorProgram);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.colorBuffer);
|
||||||
|
|
||||||
|
// Only reallocate the GPU buffer when it is too small; otherwise use
|
||||||
|
// bufferSubData which avoids a GPU reallocation (Safari is sensitive to this).
|
||||||
|
if (count > this._colorGpuSize) {
|
||||||
|
gl.bufferData(gl.ARRAY_BUFFER, this._colorScratch, gl.DYNAMIC_DRAW);
|
||||||
|
this._colorGpuSize = this._colorScratch.length;
|
||||||
|
} else {
|
||||||
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, view);
|
||||||
|
}
|
||||||
|
|
||||||
|
gl.enableVertexAttribArray(this.colorLoc.pos);
|
||||||
|
gl.vertexAttribPointer(this.colorLoc.pos, 2, gl.FLOAT, false, 24, 0);
|
||||||
|
gl.enableVertexAttribArray(this.colorLoc.color);
|
||||||
|
gl.vertexAttribPointer(this.colorLoc.color, 4, gl.FLOAT, false, 24, 8);
|
||||||
|
gl.uniform2f(this.colorLoc.resolution, this.canvas.width, this.canvas.height);
|
||||||
|
gl.drawArrays(mode, 0, count / 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
fillRect(x, y, w, h, color) {
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const v = [];
|
||||||
|
pushColoredVertex(v, x, y, rgba);
|
||||||
|
pushColoredVertex(v, x + w, y, rgba);
|
||||||
|
pushColoredVertex(v, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(v, x, y, rgba);
|
||||||
|
pushColoredVertex(v, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(v, x, y + h, rgba);
|
||||||
|
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
fillGradientRect(x, y, w, h, colorTL, colorTR, colorBR, colorBL) {
|
||||||
|
if (w <= 0 || h <= 0) return;
|
||||||
|
const tl = normalizeColor(colorTL);
|
||||||
|
const tr = normalizeColor(colorTR);
|
||||||
|
const br = normalizeColor(colorBR);
|
||||||
|
const bl = normalizeColor(colorBL);
|
||||||
|
const v = [];
|
||||||
|
pushColoredVertex(v, x, y, tl);
|
||||||
|
pushColoredVertex(v, x + w, y, tr);
|
||||||
|
pushColoredVertex(v, x + w, y + h, br);
|
||||||
|
pushColoredVertex(v, x, y, tl);
|
||||||
|
pushColoredVertex(v, x + w, y + h, br);
|
||||||
|
pushColoredVertex(v, x, y + h, bl);
|
||||||
|
this._drawColorGeometry(v, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPolyline(points, color, width = 1) {
|
||||||
|
if (!Array.isArray(points) || points.length < 4) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < points.length - 2; i += 2) {
|
||||||
|
segmentToQuadVertices(
|
||||||
|
verts,
|
||||||
|
points[i], points[i + 1],
|
||||||
|
points[i + 2], points[i + 3],
|
||||||
|
halfW,
|
||||||
|
rgba,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawSegments(segments, color, width = 1) {
|
||||||
|
if (!Array.isArray(segments) || segments.length < 4) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const halfW = Math.max(0.5, Number(width) || 1) / 2;
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < segments.length - 3; i += 4) {
|
||||||
|
segmentToQuadVertices(
|
||||||
|
verts,
|
||||||
|
segments[i], segments[i + 1],
|
||||||
|
segments[i + 2], segments[i + 3],
|
||||||
|
halfW,
|
||||||
|
rgba,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawFilledArea(points, baselineY, color) {
|
||||||
|
if (!Array.isArray(points) || points.length < 4) return;
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
pushColoredVertex(verts, points[i], baselineY, rgba);
|
||||||
|
pushColoredVertex(verts, points[i], points[i + 1], rgba);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLE_STRIP);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawPoints(points, size, color) {
|
||||||
|
if (!Array.isArray(points) || points.length < 2) return;
|
||||||
|
const radius = Math.max(1, Number(size) || 1);
|
||||||
|
const rgba = normalizeColor(color);
|
||||||
|
const verts = [];
|
||||||
|
for (let i = 0; i < points.length; i += 2) {
|
||||||
|
const x = points[i] - radius;
|
||||||
|
const y = points[i + 1] - radius;
|
||||||
|
const w = radius * 2;
|
||||||
|
const h = radius * 2;
|
||||||
|
pushColoredVertex(verts, x, y, rgba);
|
||||||
|
pushColoredVertex(verts, x + w, y, rgba);
|
||||||
|
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(verts, x, y, rgba);
|
||||||
|
pushColoredVertex(verts, x + w, y + h, rgba);
|
||||||
|
pushColoredVertex(verts, x, y + h, rgba);
|
||||||
|
}
|
||||||
|
this._drawColorGeometry(verts, this.gl.TRIANGLES);
|
||||||
|
}
|
||||||
|
|
||||||
|
drawDashedVerticalLine(x, y0, y1, dashLen, gapLen, color, width = 1) {
|
||||||
|
const dash = Math.max(1, Number(dashLen) || 1);
|
||||||
|
const gap = Math.max(1, Number(gapLen) || 1);
|
||||||
|
const top = Math.min(y0, y1);
|
||||||
|
const bottom = Math.max(y0, y1);
|
||||||
|
const segments = [];
|
||||||
|
for (let y = top; y < bottom; y += dash + gap) {
|
||||||
|
const segEnd = Math.min(bottom, y + dash);
|
||||||
|
segments.push(x, y, x, segEnd);
|
||||||
|
}
|
||||||
|
this.drawSegments(segments, color, width);
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadRgbaTexture(name, width, height, data, filter = "linear") {
|
||||||
|
if (!this.ready || !name || !data) return null;
|
||||||
|
const gl = this.gl;
|
||||||
|
let entry = this.textures.get(name);
|
||||||
|
if (!entry) {
|
||||||
|
const texture = gl.createTexture();
|
||||||
|
entry = { texture, width: 0, height: 0 };
|
||||||
|
this.textures.set(name, entry);
|
||||||
|
}
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||||
|
gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
|
||||||
|
const mode = filter === "nearest" ? gl.NEAREST : gl.LINEAR;
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, mode);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, mode);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
||||||
|
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
||||||
|
if (entry.width !== width || entry.height !== height) {
|
||||||
|
gl.texImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
0,
|
||||||
|
gl.RGBA,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
entry.width = width;
|
||||||
|
entry.height = height;
|
||||||
|
} else {
|
||||||
|
gl.texSubImage2D(
|
||||||
|
gl.TEXTURE_2D,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
gl.RGBA,
|
||||||
|
gl.UNSIGNED_BYTE,
|
||||||
|
data,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return entry.texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
drawTexture(name, x, y, w, h, alpha = 1, flipY = true) {
|
||||||
|
if (!this.ready || !name || w <= 0 || h <= 0) return;
|
||||||
|
const entry = this.textures.get(name);
|
||||||
|
if (!entry) return;
|
||||||
|
const gl = this.gl;
|
||||||
|
const s = this._texScratch;
|
||||||
|
const x2 = x + w, y2 = y + h;
|
||||||
|
if (flipY) {
|
||||||
|
s[0]=x; s[1]=y; s[2]=0; s[3]=1;
|
||||||
|
s[4]=x2; s[5]=y; s[6]=1; s[7]=1;
|
||||||
|
s[8]=x2; s[9]=y2; s[10]=1;s[11]=0;
|
||||||
|
s[12]=x; s[13]=y; s[14]=0;s[15]=1;
|
||||||
|
s[16]=x2;s[17]=y2;s[18]=1;s[19]=0;
|
||||||
|
s[20]=x; s[21]=y2;s[22]=0;s[23]=0;
|
||||||
|
} else {
|
||||||
|
s[0]=x; s[1]=y; s[2]=0; s[3]=0;
|
||||||
|
s[4]=x2; s[5]=y; s[6]=1; s[7]=0;
|
||||||
|
s[8]=x2; s[9]=y2; s[10]=1;s[11]=1;
|
||||||
|
s[12]=x; s[13]=y; s[14]=0;s[15]=0;
|
||||||
|
s[16]=x2;s[17]=y2;s[18]=1;s[19]=1;
|
||||||
|
s[20]=x; s[21]=y2;s[22]=0;s[23]=1;
|
||||||
|
}
|
||||||
|
gl.useProgram(this.textureProgram);
|
||||||
|
gl.bindBuffer(gl.ARRAY_BUFFER, this.textureBuffer);
|
||||||
|
gl.bufferSubData(gl.ARRAY_BUFFER, 0, s);
|
||||||
|
gl.enableVertexAttribArray(this.textureLoc.pos);
|
||||||
|
gl.vertexAttribPointer(this.textureLoc.pos, 2, gl.FLOAT, false, 16, 0);
|
||||||
|
gl.enableVertexAttribArray(this.textureLoc.uv);
|
||||||
|
gl.vertexAttribPointer(this.textureLoc.uv, 2, gl.FLOAT, false, 16, 8);
|
||||||
|
gl.uniform2f(this.textureLoc.resolution, this.canvas.width, this.canvas.height);
|
||||||
|
gl.uniform1f(this.textureLoc.alpha, Math.max(0, Math.min(1, Number(alpha) || 0)));
|
||||||
|
gl.activeTexture(gl.TEXTURE0);
|
||||||
|
gl.bindTexture(gl.TEXTURE_2D, entry.texture);
|
||||||
|
gl.uniform1i(this.textureLoc.tex, 0);
|
||||||
|
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRenderer(canvas, options) {
|
||||||
|
return new TrxWebGlRenderer(canvas, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
global.trxParseCssColor = parseCssColor;
|
||||||
|
global.trxHslToRgba = hslToRgba;
|
||||||
|
global.createTrxWebGlRenderer = createRenderer;
|
||||||
|
global.trxClearCssColorCache = clearCssColorCache;
|
||||||
|
})(window);
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
|
||||||
|
test("index loads the shared UI before the application", async () => {
|
||||||
|
const html = await readFile(new URL("../../assets/web/index.html", import.meta.url), "utf8");
|
||||||
|
assert.ok(html.indexOf("/ui-core.js") < html.indexOf("/app.js"));
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"useUnknownInCatchVariables": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": false,
|
||||||
|
"noEmit": true,
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"types": []
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.js"]
|
||||||
|
}
|
||||||
@@ -11,30 +11,31 @@ const CLIENT_BUILD_DATE: &str = env!("TRX_CLIENT_BUILD_DATE");
|
|||||||
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
const INDEX_HTML: &str = include_str!("../assets/web/index.html");
|
||||||
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
pub const STYLE_CSS: &str = include_str!("../assets/web/style.css");
|
||||||
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
|
pub const THEMES_CSS: &str = include_str!("../assets/web/themes.css");
|
||||||
pub const APP_JS: &str = include_str!("../assets/web/app.js");
|
pub const APP_JS: &str = include_str!("../assets/web/generated/app.js");
|
||||||
pub const UI_CORE_JS: &str = include_str!("../assets/web/ui-core.js");
|
pub const UI_CORE_JS: &str = include_str!("../assets/web/generated/ui-core.js");
|
||||||
pub const MAP_CORE_JS: &str = include_str!("../assets/web/map-core.js");
|
pub const MAP_CORE_JS: &str = include_str!("../assets/web/generated/map-core.js");
|
||||||
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/screenshot.js");
|
pub const SCREENSHOT_JS: &str = include_str!("../assets/web/generated/screenshot.js");
|
||||||
pub const DECODE_HISTORY_WORKER_JS: &str = include_str!("../assets/web/decode-history-worker.js");
|
pub const DECODE_HISTORY_WORKER_JS: &str =
|
||||||
pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/webgl-renderer.js");
|
include_str!("../assets/web/generated/decode-history-worker.js");
|
||||||
|
pub const WEBGL_RENDERER_JS: &str = include_str!("../assets/web/generated/webgl-renderer.js");
|
||||||
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
|
pub const LEAFLET_AIS_TRACKSYMBOL_JS: &str =
|
||||||
include_str!("../assets/web/leaflet-ais-tracksymbol.js");
|
include_str!("../assets/web/generated/leaflet-ais-tracksymbol.js");
|
||||||
pub const AIS_JS: &str = include_str!("../assets/web/plugins/ais.js");
|
pub const AIS_JS: &str = include_str!("../assets/web/generated/ais.js");
|
||||||
pub const VDES_JS: &str = include_str!("../assets/web/plugins/vdes.js");
|
pub const VDES_JS: &str = include_str!("../assets/web/generated/vdes.js");
|
||||||
pub const APRS_JS: &str = include_str!("../assets/web/plugins/aprs.js");
|
pub const APRS_JS: &str = include_str!("../assets/web/generated/aprs.js");
|
||||||
pub const HF_APRS_JS: &str = include_str!("../assets/web/plugins/hf-aprs.js");
|
pub const HF_APRS_JS: &str = include_str!("../assets/web/generated/hf-aprs.js");
|
||||||
pub const FT8_JS: &str = include_str!("../assets/web/plugins/ft8.js");
|
pub const FT8_JS: &str = include_str!("../assets/web/generated/ft8.js");
|
||||||
pub const FT4_JS: &str = include_str!("../assets/web/plugins/ft4.js");
|
pub const FT4_JS: &str = include_str!("../assets/web/generated/ft4.js");
|
||||||
pub const FT2_JS: &str = include_str!("../assets/web/plugins/ft2.js");
|
pub const FT2_JS: &str = include_str!("../assets/web/generated/ft2.js");
|
||||||
pub const WSPR_JS: &str = include_str!("../assets/web/plugins/wspr.js");
|
pub const WSPR_JS: &str = include_str!("../assets/web/generated/wspr.js");
|
||||||
pub const CW_JS: &str = include_str!("../assets/web/plugins/cw.js");
|
pub const CW_JS: &str = include_str!("../assets/web/generated/cw.js");
|
||||||
pub const SAT_JS: &str = include_str!("../assets/web/plugins/sat.js");
|
pub const SAT_JS: &str = include_str!("../assets/web/generated/sat.js");
|
||||||
pub const WEFAX_JS: &str = include_str!("../assets/web/plugins/wefax.js");
|
pub const WEFAX_JS: &str = include_str!("../assets/web/generated/wefax.js");
|
||||||
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/plugins/bookmarks.js");
|
pub const BOOKMARKS_JS: &str = include_str!("../assets/web/generated/bookmarks.js");
|
||||||
pub const SCHEDULER_JS: &str = include_str!("../assets/web/plugins/scheduler.js");
|
pub const SCHEDULER_JS: &str = include_str!("../assets/web/generated/scheduler.js");
|
||||||
pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/plugins/sat-scheduler.js");
|
pub const SAT_SCHEDULER_JS: &str = include_str!("../assets/web/generated/sat-scheduler.js");
|
||||||
pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/plugins/background-decode.js");
|
pub const BACKGROUND_DECODE_JS: &str = include_str!("../assets/web/generated/background-decode.js");
|
||||||
pub const VCHAN_JS: &str = include_str!("../assets/web/plugins/vchan.js");
|
pub const VCHAN_JS: &str = include_str!("../assets/web/generated/vchan.js");
|
||||||
pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
|
pub const BANDPLAN_JSON: &str = include_str!("../assets/web/bandplan.json");
|
||||||
|
|
||||||
// Vendored DSEG14 Classic font
|
// Vendored DSEG14 Classic font
|
||||||
|
|||||||
Reference in New Issue
Block a user