build: enable frontend module code splitting
This commit is contained in:
@@ -1,334 +1,331 @@
|
|||||||
"use strict";
|
// src/plugins/ais.ts
|
||||||
(() => {
|
var aisWindow = window;
|
||||||
// src/plugins/ais.ts
|
var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
var aisWindow = window;
|
var aisStatus = document.getElementById("ais-status");
|
||||||
var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
var aisMessagesEl = document.getElementById("ais-messages");
|
||||||
var aisStatus = document.getElementById("ais-status");
|
var aisFilterInput = document.getElementById("ais-filter");
|
||||||
var aisMessagesEl = document.getElementById("ais-messages");
|
var aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
var aisFilterInput = document.getElementById("ais-filter");
|
var aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
var aisBarOverlay = document.getElementById("ais-bar-overlay");
|
var aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
var aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
var aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||||
var aisVesselCountEl = document.getElementById("ais-vessel-count");
|
var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
var aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
var AIS_DEFAULT_A_HZ = 161975e3;
|
||||||
var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
var AIS_CHANNEL_SPACING_HZ = 5e4;
|
||||||
var AIS_DEFAULT_A_HZ = 161975e3;
|
var aisFilterText = "";
|
||||||
var AIS_CHANNEL_SPACING_HZ = 5e4;
|
var aisMessageHistory = [];
|
||||||
var aisFilterText = "";
|
function currentAisHistoryRetentionMs() {
|
||||||
var aisMessageHistory = [];
|
return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
function currentAisHistoryRetentionMs() {
|
}
|
||||||
return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
function pruneAisMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
|
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAisUi(key, job) {
|
||||||
|
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
aisWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function pruneAisMessageHistory() {
|
job();
|
||||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
}
|
||||||
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
function scheduleAisHistoryRender() {
|
||||||
}
|
scheduleAisUi("ais-history", () => {
|
||||||
function scheduleAisUi(key, job) {
|
renderAisHistory();
|
||||||
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
});
|
||||||
aisWindow.trxScheduleUiFrameJob(key, job);
|
}
|
||||||
return;
|
function scheduleAisBarUpdate() {
|
||||||
}
|
scheduleAisUi("ais-bar", () => {
|
||||||
job();
|
updateAisBar();
|
||||||
}
|
});
|
||||||
function scheduleAisHistoryRender() {
|
}
|
||||||
scheduleAisUi("ais-history", () => {
|
function formatAisMhz(freqHz) {
|
||||||
renderAisHistory();
|
return `${(freqHz / 1e6).toFixed(3)} MHz`;
|
||||||
});
|
}
|
||||||
}
|
function currentAisChannelPlan() {
|
||||||
function scheduleAisBarUpdate() {
|
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
||||||
scheduleAisUi("ais-bar", () => {
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
updateAisBar();
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
});
|
return {
|
||||||
}
|
aHz: safeAHz,
|
||||||
function formatAisMhz(freqHz) {
|
bHz: safeAHz + AIS_CHANNEL_SPACING_HZ
|
||||||
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 = (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 = escapeAisHtml(aisDisplayName(msg));
|
|
||||||
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
|
||||||
if (!url) return label;
|
|
||||||
return `<a class="title-link" href="${escapeAisHtml(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 (typeof tsMs !== "number" || !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 ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
|
||||||
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
|
||||||
msg.heading_deg != null ? `${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 (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.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}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(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 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 ?? 0) >= 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 ${escapeAisHtml(String(msg.mmsi))}`,
|
|
||||||
escapeAisHtml(channel.label),
|
|
||||||
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
|
||||||
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
|
||||||
distance ? escapeAisHtml(distance) : null,
|
|
||||||
escapeAisHtml(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";
|
|
||||||
}
|
|
||||||
aisWindow.updateAisBar = updateAisBar;
|
|
||||||
aisWindow.clearAisBar = function() {
|
|
||||||
resetAisHistoryView();
|
|
||||||
};
|
};
|
||||||
function resetAisHistoryView() {
|
}
|
||||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
function aisChannelInfo(channel) {
|
||||||
aisMessageHistory = [];
|
const plan = currentAisChannelPlan();
|
||||||
updateAisBar();
|
const ch = (channel ?? "").trim().toUpperCase();
|
||||||
renderAisHistory();
|
if (ch === "B") {
|
||||||
aisWindow.clearMapMarkersByType?.("ais");
|
return {
|
||||||
|
label: "AIS-B",
|
||||||
|
badgeClass: "ais-badge ais-badge-channel-b",
|
||||||
|
freqText: formatAisMhz(plan.bHz)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
function renderAisHistory() {
|
return {
|
||||||
pruneAisMessageHistory();
|
label: "AIS-A",
|
||||||
if (!aisMessagesEl) {
|
badgeClass: "ais-badge ais-badge-channel-a",
|
||||||
updateAisSummary();
|
freqText: formatAisMhz(plan.aHz)
|
||||||
return;
|
};
|
||||||
|
}
|
||||||
|
function aisDisplayName(msg) {
|
||||||
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
|
}
|
||||||
|
function aisDisplayNameHtml(msg) {
|
||||||
|
const label = escapeAisHtml(aisDisplayName(msg));
|
||||||
|
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
|
if (!url) return label;
|
||||||
|
return `<a class="title-link" href="${escapeAisHtml(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 (typeof tsMs !== "number" || !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 ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
||||||
|
msg.heading_deg != null ? `${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 (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.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)}`;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
}
|
||||||
for (const message of aisMessageHistory) {
|
}
|
||||||
fragment.appendChild(renderAisRow(message));
|
function renderAisRow(msg) {
|
||||||
}
|
const row = document.createElement("div");
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
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}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(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 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 ?? 0) >= 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 ${escapeAisHtml(String(msg.mmsi))}`,
|
||||||
|
escapeAisHtml(channel.label),
|
||||||
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
||||||
|
distance ? escapeAisHtml(distance) : null,
|
||||||
|
escapeAisHtml(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";
|
||||||
|
}
|
||||||
|
aisWindow.updateAisBar = updateAisBar;
|
||||||
|
aisWindow.clearAisBar = function() {
|
||||||
|
resetAisHistoryView();
|
||||||
|
};
|
||||||
|
function resetAisHistoryView() {
|
||||||
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
|
aisMessageHistory = [];
|
||||||
|
updateAisBar();
|
||||||
|
renderAisHistory();
|
||||||
|
aisWindow.clearMapMarkersByType?.("ais");
|
||||||
|
}
|
||||||
|
function renderAisHistory() {
|
||||||
|
pruneAisMessageHistory();
|
||||||
|
if (!aisMessagesEl) {
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function addAisMessage(msg) {
|
const fragment = document.createDocumentFragment();
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
for (const message of aisMessageHistory) {
|
||||||
msg._tsMs = tsMs;
|
fragment.appendChild(renderAisRow(message));
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
}
|
||||||
|
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 && aisWindow.aisMapAddVessel) {
|
||||||
|
aisWindow.aisMapAddVessel(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function normalizeServerAisMessage(msg) {
|
||||||
|
return {
|
||||||
|
...msg,
|
||||||
|
rig_id: msg.rig_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerAisBatch(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",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
aisMessageHistory.unshift(msg);
|
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
pruneAisMessageHistory();
|
aisWindow.aisMapAddVessel(next);
|
||||||
scheduleAisBarUpdate();
|
|
||||||
scheduleAisHistoryRender();
|
|
||||||
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
|
||||||
aisWindow.aisMapAddVessel(msg);
|
|
||||||
}
|
}
|
||||||
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
function normalizeServerAisMessage(msg) {
|
normalized.reverse();
|
||||||
return {
|
aisMessageHistory = normalized.concat(aisMessageHistory);
|
||||||
...msg,
|
pruneAisMessageHistory();
|
||||||
rig_id: msg.rig_id || null
|
scheduleAisBarUpdate();
|
||||||
};
|
scheduleAisHistoryRender();
|
||||||
}
|
}
|
||||||
function onServerAisBatch(messages) {
|
function pruneAisHistoryView() {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
pruneAisMessageHistory();
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
updateAisBar();
|
||||||
const normalized = [];
|
renderAisHistory();
|
||||||
for (const msg of messages) {
|
}
|
||||||
const next = normalizeServerAisMessage(msg);
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
void (async () => {
|
||||||
next._tsMs = tsMs;
|
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
try {
|
||||||
hour: "2-digit",
|
await aisWindow.postPath?.("/clear_ais_decode");
|
||||||
minute: "2-digit",
|
resetAisHistoryView();
|
||||||
second: "2-digit"
|
} catch (e) {
|
||||||
});
|
console.error("AIS history clear failed", e);
|
||||||
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
|
||||||
aisWindow.aisMapAddVessel(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
|
||||||
}
|
}
|
||||||
normalized.reverse();
|
})();
|
||||||
aisMessageHistory = normalized.concat(aisMessageHistory);
|
});
|
||||||
pruneAisMessageHistory();
|
if (aisFilterInput) {
|
||||||
scheduleAisBarUpdate();
|
aisFilterInput.addEventListener("input", () => {
|
||||||
scheduleAisHistoryRender();
|
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
||||||
}
|
|
||||||
function pruneAisHistoryView() {
|
|
||||||
pruneAisMessageHistory();
|
|
||||||
updateAisBar();
|
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
}
|
|
||||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await aisWindow.postPath?.("/clear_ais_decode");
|
|
||||||
resetAisHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("AIS history clear failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
});
|
||||||
if (aisFilterInput) {
|
}
|
||||||
aisFilterInput.addEventListener("input", () => {
|
function onServerAis(msg) {
|
||||||
aisFilterText = aisFilterInput.value.trim().toUpperCase();
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
renderAisHistory();
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
});
|
}
|
||||||
}
|
updateAisSummary();
|
||||||
function onServerAis(msg) {
|
window.trxPluginRuntime.registerDecoder({
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
id: "ais",
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
onMessage: onServerAis,
|
||||||
}
|
onBatch: onServerAisBatch,
|
||||||
updateAisSummary();
|
restore: onServerAisBatch,
|
||||||
window.trxPluginRuntime.registerDecoder({
|
reset: resetAisHistoryView,
|
||||||
id: "ais",
|
prune: pruneAisHistoryView
|
||||||
onMessage: onServerAis,
|
});
|
||||||
onBatch: onServerAisBatch,
|
|
||||||
restore: onServerAisBatch,
|
|
||||||
reset: resetAisHistoryView,
|
|
||||||
prune: pruneAisHistoryView
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,408 +1,314 @@
|
|||||||
"use strict";
|
import {
|
||||||
(() => {
|
aprsAgeText,
|
||||||
// src/plugins/aprs-shared.ts
|
aprsCategoryLabel,
|
||||||
function aprsPacketCategory(packet) {
|
aprsHexBytes,
|
||||||
const type = (packet.type ?? "").toLowerCase();
|
aprsPacketCategory,
|
||||||
const info = (packet.info ?? "").toLowerCase();
|
collapseAprsDuplicates,
|
||||||
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
normalizeAprsPacket,
|
||||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
renderAprsInfo,
|
||||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
renderLocalAprsSymbol
|
||||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
} from "./chunk-M2I6DH4X.js";
|
||||||
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(timestampMs) {
|
|
||||||
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
|
||||||
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${String(seconds)}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${String(minutes)}m ago`;
|
|
||||||
return `${String(Math.round(minutes / 60))}h ago`;
|
|
||||||
}
|
|
||||||
function aprsPacketSignature(packet) {
|
|
||||||
return [
|
|
||||||
packet.srcCall ?? "",
|
|
||||||
packet.destCall ?? "",
|
|
||||||
packet.path ?? "",
|
|
||||||
packet.info ?? "",
|
|
||||||
packet.type ?? "",
|
|
||||||
packet.lat?.toFixed(4) ?? "",
|
|
||||||
packet.lon?.toFixed(4) ?? ""
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
function collapseAprsDuplicates(packets) {
|
|
||||||
const seen = /* @__PURE__ */ new Set();
|
|
||||||
return packets.filter((packet) => {
|
|
||||||
const signature = aprsPacketSignature(packet);
|
|
||||||
if (seen.has(signature)) return false;
|
|
||||||
seen.add(signature);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function aprsHexBytes(bytes) {
|
|
||||||
if (!bytes?.length) return "--";
|
|
||||||
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
function renderAprsInfo(packet) {
|
|
||||||
if (packet.info_bytes?.length) {
|
|
||||||
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
|
||||||
}
|
|
||||||
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
|
||||||
}
|
|
||||||
function renderAprsByte(byte) {
|
|
||||||
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function renderAprsCharacter(character) {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function escapeAprsCharacter(character) {
|
|
||||||
if (character === "<") return "<";
|
|
||||||
if (character === ">") return ">";
|
|
||||||
if (character === "&") return "&";
|
|
||||||
if (character === '"') return """;
|
|
||||||
return character;
|
|
||||||
}
|
|
||||||
function renderLocalAprsSymbol(packet, escapeHtml) {
|
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
|
||||||
const symbol = escapeHtml(packet.symbolCode);
|
|
||||||
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
|
||||||
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
|
||||||
}
|
|
||||||
function normalizeAprsPacket(packet, receiver) {
|
|
||||||
return {
|
|
||||||
rig_id: packet.rig_id || null,
|
|
||||||
receiver,
|
|
||||||
srcCall: packet.src_call ?? "",
|
|
||||||
destCall: packet.dest_call ?? "",
|
|
||||||
path: packet.path ?? "",
|
|
||||||
info: packet.info ?? "",
|
|
||||||
info_bytes: packet.info_bytes ?? [],
|
|
||||||
type: packet.packet_type ?? "",
|
|
||||||
crcOk: packet.crc_ok ?? false,
|
|
||||||
ts_ms: packet.ts_ms ?? null,
|
|
||||||
lat: packet.lat ?? null,
|
|
||||||
lon: packet.lon ?? null,
|
|
||||||
symbolTable: packet.symbol_table ?? null,
|
|
||||||
symbolCode: packet.symbol_code ?? null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/plugins/aprs.ts
|
// src/plugins/aprs.ts
|
||||||
var aprsWindow = window;
|
var aprsWindow = window;
|
||||||
var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
var showAprsHint = (message, durationMs) => {
|
var showAprsHint = (message, durationMs) => {
|
||||||
aprsWindow.showHint?.(message, durationMs);
|
aprsWindow.showHint?.(message, durationMs);
|
||||||
};
|
};
|
||||||
var aprsStatus = document.getElementById("aprs-status");
|
var aprsStatus = document.getElementById("aprs-status");
|
||||||
var aprsPacketsEl = document.getElementById("aprs-packets");
|
var aprsPacketsEl = document.getElementById("aprs-packets");
|
||||||
var aprsFilterInput = document.getElementById("aprs-filter");
|
var aprsFilterInput = document.getElementById("aprs-filter");
|
||||||
var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
var aprsBarOverlay = document.getElementById("aprs-bar-overlay");
|
||||||
var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn");
|
||||||
var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn");
|
||||||
var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn");
|
||||||
var aprsTotalCountEl = document.getElementById("aprs-total-count");
|
var aprsTotalCountEl = document.getElementById("aprs-total-count");
|
||||||
var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
var aprsVisibleCountEl = document.getElementById("aprs-visible-count");
|
||||||
var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
var aprsLatestSeenEl = document.getElementById("aprs-latest-seen");
|
||||||
var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
var aprsFilterText = "";
|
var aprsFilterText = "";
|
||||||
var aprsPacketHistory = [];
|
var aprsPacketHistory = [];
|
||||||
var aprsBarDismissedAtMs = 0;
|
var aprsBarDismissedAtMs = 0;
|
||||||
var aprsOnlyPos = false;
|
var aprsOnlyPos = false;
|
||||||
var aprsHideCrc = false;
|
var aprsHideCrc = false;
|
||||||
var aprsCollapseDup = false;
|
var aprsCollapseDup = false;
|
||||||
var aprsTypeFilter = "all";
|
var aprsTypeFilter = "all";
|
||||||
function currentAprsHistoryRetentionMs() {
|
function currentAprsHistoryRetentionMs() {
|
||||||
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
|
}
|
||||||
|
function pruneAprsPacketHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
||||||
|
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleAprsUi(key, job) {
|
||||||
|
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
aprsWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function pruneAprsPacketHistory() {
|
job();
|
||||||
const cutoffMs = Date.now() - currentAprsHistoryRetentionMs();
|
}
|
||||||
aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
function scheduleAprsHistoryRender() {
|
||||||
|
scheduleAprsUi("aprs-history", () => {
|
||||||
|
renderAprsHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function scheduleAprsBarUpdate() {
|
||||||
|
scheduleAprsUi("aprs-bar", () => {
|
||||||
|
updateAprsBar();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsDistanceText(pkt) {
|
||||||
|
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
|
||||||
|
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.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 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 updateAprsSummary() {
|
||||||
|
const visible = aprsVisiblePackets();
|
||||||
|
if (aprsTotalCountEl) {
|
||||||
|
aprsTotalCountEl.textContent = `${aprsPacketHistory.length} total`;
|
||||||
}
|
}
|
||||||
function scheduleAprsUi(key, job) {
|
if (aprsVisibleCountEl) {
|
||||||
if (typeof aprsWindow.trxScheduleUiFrameJob === "function") {
|
aprsVisibleCountEl.textContent = `${visible.length} shown`;
|
||||||
aprsWindow.trxScheduleUiFrameJob(key, job);
|
}
|
||||||
return;
|
if (aprsLatestSeenEl) {
|
||||||
|
const latest = aprsPacketHistory[0];
|
||||||
|
if (!latest) {
|
||||||
|
aprsLatestSeenEl.textContent = "No packets yet";
|
||||||
|
} else {
|
||||||
|
aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`;
|
||||||
}
|
}
|
||||||
job();
|
|
||||||
}
|
}
|
||||||
function scheduleAprsHistoryRender() {
|
}
|
||||||
scheduleAprsUi("aprs-history", () => {
|
function updateAprsChipState() {
|
||||||
renderAprsHistory();
|
document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => {
|
||||||
});
|
btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`);
|
||||||
}
|
});
|
||||||
function scheduleAprsBarUpdate() {
|
aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos);
|
||||||
scheduleAprsUi("aprs-bar", () => {
|
aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc);
|
||||||
updateAprsBar();
|
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
||||||
});
|
}
|
||||||
}
|
function renderAprsRow(pkt, isFresh) {
|
||||||
function aprsDistanceText(pkt) {
|
const row = document.createElement("div");
|
||||||
if (aprsWindow.serverLat == null || aprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !aprsWindow.haversineKm) return "";
|
row.className = "aprs-packet";
|
||||||
const distKm = aprsWindow.haversineKm(aprsWindow.serverLat, aprsWindow.serverLon, pkt.lat, pkt.lon);
|
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (isFresh) row.classList.add("aprs-packet-new");
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
const age = aprsAgeText(pkt._tsMs);
|
||||||
}
|
const category = aprsPacketCategory(pkt);
|
||||||
function aprsFilterMatch(pkt) {
|
const categoryLabel = aprsCategoryLabel(category);
|
||||||
if (aprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`;
|
||||||
if (aprsHideCrc && !pkt.crcOk) return false;
|
const pathBadge = pkt.path ? `<span class="aprs-badge">${escapeAprsHtml(pkt.path)}</span>` : "";
|
||||||
if (aprsTypeFilter !== "all" && aprsPacketCategory(pkt) !== aprsTypeFilter) return false;
|
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
||||||
if (!aprsFilterText) return true;
|
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
||||||
const haystack = [
|
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>` : "";
|
||||||
pkt.srcCall,
|
const distance = aprsDistanceText(pkt);
|
||||||
pkt.destCall,
|
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`;
|
||||||
pkt.path,
|
row.innerHTML = `<div class="aprs-row-head"><span class="aprs-time">${ts}</span>` + symbolHtml + `<span class="aprs-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(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">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(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">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
||||||
pkt.info,
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
pkt.type,
|
el.addEventListener("click", (evt) => {
|
||||||
pkt.lat != null ? pkt.lat.toFixed(4) : "",
|
evt.preventDefault();
|
||||||
pkt.lon != null ? pkt.lon.toFixed(4) : "",
|
const raw = el.dataset.aprsMap ?? "";
|
||||||
aprsPacketCategory(pkt)
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
].filter(Boolean).join(" ").toUpperCase();
|
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
return haystack.includes(aprsFilterText);
|
aprsWindow.navigateToAprsMap(lat, lon);
|
||||||
}
|
|
||||||
function aprsVisiblePackets() {
|
|
||||||
const packets = aprsCollapseDup ? collapseAprsDuplicates(aprsPacketHistory) : aprsPacketHistory;
|
|
||||||
return packets.filter(aprsFilterMatch);
|
|
||||||
}
|
|
||||||
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);
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup);
|
if (copyBtn) {
|
||||||
}
|
copyBtn.addEventListener("click", () => {
|
||||||
function renderAprsRow(pkt, isFresh) {
|
void (async () => {
|
||||||
const row = document.createElement("div");
|
const raw = copyBtn.dataset.aprsCopy ?? "";
|
||||||
row.className = "aprs-packet";
|
try {
|
||||||
if (!pkt.crcOk) row.classList.add("aprs-packet-crc");
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
if (isFresh) row.classList.add("aprs-packet-new");
|
if (clipboard) {
|
||||||
const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
await clipboard.writeText(raw);
|
||||||
const age = aprsAgeText(pkt._tsMs);
|
showAprsHint("Coordinates copied", 1200);
|
||||||
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">${escapeAprsHtml(pkt.path)}</span>` : "";
|
|
||||||
const crcBadge = pkt.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC Fail</span>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml);
|
|
||||||
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">${escapeAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeAprsHtml(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">${escapeAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeAprsHtml(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">${escapeAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
aprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
|
||||||
try {
|
|
||||||
const clipboard = Reflect.get(navigator, "clipboard");
|
|
||||||
if (clipboard) {
|
|
||||||
await clipboard.writeText(raw);
|
|
||||||
showAprsHint("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showAprsHint("Copy failed", 1500);
|
|
||||||
}
|
}
|
||||||
})();
|
} catch {
|
||||||
});
|
showAprsHint("Copy failed", 1500);
|
||||||
}
|
}
|
||||||
return row;
|
})();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function renderAprsHistory() {
|
return row;
|
||||||
pruneAprsPacketHistory();
|
}
|
||||||
if (!aprsPacketsEl) {
|
function renderAprsHistory() {
|
||||||
updateAprsSummary();
|
pruneAprsPacketHistory();
|
||||||
updateAprsChipState();
|
if (!aprsPacketsEl) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
const visible = aprsVisiblePackets();
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (const [index, packet] of visible.entries()) {
|
|
||||||
fragment.appendChild(renderAprsRow(packet, index === 0));
|
|
||||||
}
|
|
||||||
aprsPacketsEl.replaceChildren(fragment);
|
|
||||||
updateAprsSummary();
|
updateAprsSummary();
|
||||||
updateAprsChipState();
|
updateAprsChipState();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function updateAprsBar() {
|
const visible = aprsVisiblePackets();
|
||||||
if (!aprsBarOverlay) return;
|
const fragment = document.createDocumentFragment();
|
||||||
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
for (const [index, packet] of visible.entries()) {
|
||||||
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
fragment.appendChild(renderAprsRow(packet, index === 0));
|
||||||
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= 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">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
|
||||||
const dest = escapeAprsHtml(pkt.destCall || "");
|
|
||||||
const info = escapeAprsHtml(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";
|
|
||||||
}
|
}
|
||||||
aprsWindow.updateAprsBar = updateAprsBar;
|
aprsPacketsEl.replaceChildren(fragment);
|
||||||
aprsWindow.clearAprsBar = function() {
|
updateAprsSummary();
|
||||||
resetAprsHistoryView();
|
updateAprsChipState();
|
||||||
};
|
}
|
||||||
aprsWindow.closeAprsBar = function() {
|
function updateAprsBar() {
|
||||||
aprsBarDismissedAtMs = Date.now();
|
if (!aprsBarOverlay) return;
|
||||||
if (aprsBarOverlay) {
|
const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT";
|
||||||
aprsBarOverlay.style.display = "none";
|
const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS;
|
||||||
aprsBarOverlay.innerHTML = "";
|
const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 0) >= cutoffMs);
|
||||||
}
|
const frames = collapseAprsDuplicates(okFrames).slice(0, 8);
|
||||||
};
|
const newestTsMs = frames.reduce((latest, pkt) => Math.max(latest, Number(pkt._tsMs) || 0), 0);
|
||||||
function resetAprsHistoryView() {
|
if (!isPkt || frames.length === 0 || newestTsMs <= aprsBarDismissedAtMs) {
|
||||||
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
aprsBarOverlay.style.display = "none";
|
||||||
aprsPacketHistory = [];
|
aprsBarOverlay.innerHTML = "";
|
||||||
updateAprsBar();
|
return;
|
||||||
renderAprsHistory();
|
|
||||||
aprsWindow.clearMapMarkersByType?.("aprs");
|
|
||||||
}
|
}
|
||||||
function pruneAprsHistoryView() {
|
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>`;
|
||||||
pruneAprsPacketHistory();
|
for (const pkt of frames) {
|
||||||
updateAprsBar();
|
const ts = pkt._ts ? `<span class="aprs-bar-time">${pkt._ts}</span>` : "";
|
||||||
renderAprsHistory();
|
const call = `<span class="aprs-bar-call">${escapeAprsHtml(pkt.srcCall ?? "")}</span>`;
|
||||||
|
const dest = escapeAprsHtml(pkt.destCall || "");
|
||||||
|
const info = escapeAprsHtml(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>`;
|
||||||
}
|
}
|
||||||
function addAprsPacket(pkt) {
|
aprsBarOverlay.innerHTML = html;
|
||||||
const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now();
|
aprsBarOverlay.style.display = "flex";
|
||||||
pkt._tsMs = tsMs;
|
}
|
||||||
pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
aprsWindow.updateAprsBar = updateAprsBar;
|
||||||
aprsPacketHistory.unshift(pkt);
|
aprsWindow.clearAprsBar = function() {
|
||||||
pruneAprsPacketHistory();
|
resetAprsHistoryView();
|
||||||
if (pkt.lat != null && pkt.lon != null && aprsWindow.aprsMapAddStation) {
|
};
|
||||||
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
aprsWindow.closeAprsBar = function() {
|
||||||
}
|
aprsBarDismissedAtMs = Date.now();
|
||||||
if (pkt.crcOk) scheduleAprsBarUpdate();
|
if (aprsBarOverlay) {
|
||||||
scheduleAprsHistoryRender();
|
aprsBarOverlay.style.display = "none";
|
||||||
}
|
aprsBarOverlay.innerHTML = "";
|
||||||
function normalizeServerAprsPacket(pkt) {
|
|
||||||
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
|
|
||||||
}
|
|
||||||
function onServerAprsBatch(packets) {
|
|
||||||
if (!Array.isArray(packets) || packets.length === 0) return;
|
|
||||||
if (aprsStatus) 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 && aprsWindow.aprsMapAddStation) {
|
|
||||||
aprsWindow.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();
|
|
||||||
}
|
|
||||||
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await aprsWindow.postPath?.("/clear_aprs_decode");
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onServerAprs(pkt) {
|
|
||||||
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
|
||||||
addAprsPacket(normalizeServerAprsPacket(pkt));
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
function resetAprsHistoryView() {
|
||||||
|
if (aprsPacketsEl) aprsPacketsEl.innerHTML = "";
|
||||||
|
aprsPacketHistory = [];
|
||||||
|
updateAprsBar();
|
||||||
renderAprsHistory();
|
renderAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
aprsWindow.clearMapMarkersByType?.("aprs");
|
||||||
id: "aprs",
|
}
|
||||||
onMessage: onServerAprs,
|
function pruneAprsHistoryView() {
|
||||||
onBatch: onServerAprsBatch,
|
pruneAprsPacketHistory();
|
||||||
restore: onServerAprsBatch,
|
updateAprsBar();
|
||||||
reset: resetAprsHistoryView,
|
renderAprsHistory();
|
||||||
prune: pruneAprsHistoryView
|
}
|
||||||
|
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 && aprsWindow.aprsMapAddStation) {
|
||||||
|
aprsWindow.aprsMapAddStation(pkt.srcCall ?? "", pkt.lat, pkt.lon, pkt.info ?? "", pkt.symbolTable, pkt.symbolCode, pkt);
|
||||||
|
}
|
||||||
|
if (pkt.crcOk) scheduleAprsBarUpdate();
|
||||||
|
scheduleAprsHistoryRender();
|
||||||
|
}
|
||||||
|
function normalizeServerAprsPacket(pkt) {
|
||||||
|
return normalizeAprsPacket(pkt, aprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
|
}
|
||||||
|
function onServerAprsBatch(packets) {
|
||||||
|
if (!Array.isArray(packets) || packets.length === 0) return;
|
||||||
|
if (aprsStatus) 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 && aprsWindow.aprsMapAddStation) {
|
||||||
|
aprsWindow.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();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-aprs-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await aprsWindow.trxUi.confirm({ title: "Clear APRS history?", message: "All stored APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await aprsWindow.postPath?.("/clear_aprs_decode");
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerAprs(pkt) {
|
||||||
|
if (aprsStatus) aprsStatus.textContent = "Receiving";
|
||||||
|
addAprsPacket(normalizeServerAprsPacket(pkt));
|
||||||
|
}
|
||||||
|
renderAprsHistory();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "aprs",
|
||||||
|
onMessage: onServerAprs,
|
||||||
|
onBatch: onServerAprsBatch,
|
||||||
|
restore: onServerAprsBatch,
|
||||||
|
reset: resetAprsHistoryView,
|
||||||
|
prune: pruneAprsHistoryView
|
||||||
|
});
|
||||||
|
|||||||
+349
-352
@@ -1,365 +1,362 @@
|
|||||||
"use strict";
|
// src/plugins/background-decode.ts
|
||||||
(() => {
|
var bgdWindow = window;
|
||||||
// src/plugins/background-decode.ts
|
(function() {
|
||||||
var bgdWindow = window;
|
"use strict";
|
||||||
(function() {
|
function bgdSupportedIds() {
|
||||||
"use strict";
|
return (bgdWindow.decoderRegistry || []).filter(function(d) {
|
||||||
function bgdSupportedIds() {
|
return d.background_decode;
|
||||||
return (bgdWindow.decoderRegistry || []).filter(function(d) {
|
}).map(function(d) {
|
||||||
return d.background_decode;
|
return d.id;
|
||||||
}).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 ${String(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 ${String(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 ${String(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 ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function apiGetBookmarks() {
|
||||||
|
return fetch("/bookmarks").then(function(r) {
|
||||||
|
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
||||||
|
return r.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function loadBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) {
|
||||||
|
currentConfig = config;
|
||||||
|
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) {
|
||||||
|
const ids = bgdSupportedIds();
|
||||||
|
const decoders = bookmark.decoders ?? [];
|
||||||
|
const explicit = decoders.map(function(item) {
|
||||||
|
return item.trim().toLowerCase();
|
||||||
|
}).filter(function(item, index, arr) {
|
||||||
|
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
||||||
|
});
|
||||||
|
if (explicit.length > 0) return explicit;
|
||||||
|
const mode = bookmark.mode.trim().toUpperCase();
|
||||||
|
return (bgdWindow.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" || bgdWindow.authEnabled === false;
|
||||||
|
const panel = document.getElementById("background-decode-panel");
|
||||||
|
if (panel) {
|
||||||
|
panel.querySelectorAll("input, select, button.sch-write").forEach(function(el) {
|
||||||
|
el.disabled = !isControl;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let backgroundDecodeRole = null;
|
const saveBtn = document.getElementById("background-decode-save-btn");
|
||||||
let currentRigId = null;
|
const resetBtn = document.getElementById("background-decode-reset-btn");
|
||||||
let currentConfig = null;
|
if (saveBtn) saveBtn.style.display = isControl ? "" : "none";
|
||||||
let bookmarkList = [];
|
if (resetBtn) resetBtn.style.display = isControl ? "" : "none";
|
||||||
let statusInterval = null;
|
}
|
||||||
let bgdDirty = false;
|
function renderBookmarkChecklist(filterText = "") {
|
||||||
function initBackgroundDecode(rigId, role) {
|
const container = document.getElementById("bgd-bookmark-checklist");
|
||||||
backgroundDecodeRole = role;
|
if (!container) return;
|
||||||
currentRigId = rigId || null;
|
container.innerHTML = "";
|
||||||
if (currentRigId) loadBackgroundDecode();
|
const selectedIds = new Set(
|
||||||
startStatusPolling();
|
currentConfig && Array.isArray(currentConfig.bookmark_ids) ? currentConfig.bookmark_ids : []
|
||||||
|
);
|
||||||
|
const all = supportedBookmarks();
|
||||||
|
const filter = (filterText || "").trim().toLowerCase();
|
||||||
|
const filtered = filter ? all.filter(function(bm) {
|
||||||
|
const 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;
|
||||||
}
|
}
|
||||||
function setBackgroundDecodeRig(rigId) {
|
filtered.forEach(function(bookmark) {
|
||||||
const nextRigId = rigId || null;
|
const row = document.createElement("label");
|
||||||
if (nextRigId === currentRigId) return;
|
row.className = "bgd-checklist-row";
|
||||||
currentRigId = nextRigId;
|
const decoders = bookmarkDecoderKinds(bookmark);
|
||||||
if (!currentRigId) return;
|
const checked = selectedIds.has(bookmark.id) ? " checked" : "";
|
||||||
loadBackgroundDecode();
|
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.currentTarget.checked);
|
||||||
|
});
|
||||||
|
container.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onChecklistToggle(bookmarkId, checked) {
|
||||||
|
if (!currentConfig) {
|
||||||
|
currentConfig = { remote: currentRigId, enabled: false, bookmark_ids: [] };
|
||||||
}
|
}
|
||||||
function apiGetConfig(rigId) {
|
if (!Array.isArray(currentConfig.bookmark_ids)) currentConfig.bookmark_ids = [];
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) {
|
if (checked && !currentConfig.bookmark_ids.includes(bookmarkId)) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
currentConfig.bookmark_ids.push(bookmarkId);
|
||||||
return r.json();
|
} else if (!checked) {
|
||||||
|
currentConfig.bookmark_ids = currentConfig.bookmark_ids.filter(function(id) {
|
||||||
|
return id !== bookmarkId;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function apiPutConfig(rigId, config) {
|
markBgdDirty();
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
}
|
||||||
method: "PUT",
|
function saveBackgroundDecode() {
|
||||||
headers: { "Content-Type": "application/json" },
|
const rigId = currentRigId;
|
||||||
body: JSON.stringify(config)
|
if (!rigId) return;
|
||||||
}).then(function(r) {
|
const payload = {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
remote: rigId,
|
||||||
return r.json();
|
enabled: document.getElementById("background-decode-enabled")?.checked ?? false,
|
||||||
|
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.", false);
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast(`Save failed: ${errorMessage(err)}`, true);
|
||||||
|
}).finally(function() {
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function resetBackgroundDecode() {
|
||||||
|
const rigId = currentRigId;
|
||||||
|
if (!rigId) return;
|
||||||
|
if (!await bgdWindow.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.", false);
|
||||||
|
}).catch(function(err) {
|
||||||
|
showToast(`Reset failed: ${errorMessage(err)}`, 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 = status.entries ?? [];
|
||||||
|
if (!entries.length) {
|
||||||
|
card.textContent = "No background decode bookmarks configured.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const summary = [];
|
||||||
|
if (status.active_rig) {
|
||||||
|
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
||||||
|
if (typeof status.sample_rate === "number" && 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 (typeof entry.freq_hz === "number" && 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 `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function escHtml(value) {
|
||||||
|
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
|
||||||
|
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function errorMessage(error) {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
function markBgdDirty() {
|
||||||
|
if (bgdDirty) return;
|
||||||
|
bgdDirty = true;
|
||||||
|
const btn = document.getElementById("background-decode-save-btn");
|
||||||
|
if (btn) btn.classList.add("sch-dirty");
|
||||||
|
}
|
||||||
|
function clearBgdDirty() {
|
||||||
|
bgdDirty = false;
|
||||||
|
const 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: [] };
|
||||||
|
}
|
||||||
|
const 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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function apiResetConfig(rigId) {
|
const enabledCb = document.getElementById("background-decode-enabled");
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId), {
|
if (enabledCb && !enabledCb._wired) {
|
||||||
method: "DELETE"
|
enabledCb._wired = true;
|
||||||
}).then(function(r) {
|
enabledCb.addEventListener("change", function() {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
markBgdDirty();
|
||||||
return r.json();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function apiGetStatus(rigId) {
|
const selectAllBtn = document.getElementById("bgd-select-all-btn");
|
||||||
return fetch("/background-decode/" + encodeURIComponent(rigId) + "/status").then(function(r) {
|
if (selectAllBtn && !selectAllBtn._wired) {
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
selectAllBtn._wired = true;
|
||||||
return r.json();
|
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", () => {
|
||||||
|
void resetBackgroundDecode();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function apiGetBookmarks() {
|
}
|
||||||
return fetch("/bookmarks").then(function(r) {
|
bgdWindow.initBackgroundDecode = initBackgroundDecode;
|
||||||
if (!r.ok) throw new Error(`HTTP ${String(r.status)}`);
|
bgdWindow.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
||||||
return r.json();
|
bgdWindow.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
||||||
});
|
|
||||||
}
|
|
||||||
function loadBackgroundDecode() {
|
|
||||||
const rigId = currentRigId;
|
|
||||||
if (!rigId) return;
|
|
||||||
Promise.all([apiGetConfig(rigId), apiGetBookmarks()]).then(function([config, bookmarks]) {
|
|
||||||
currentConfig = config;
|
|
||||||
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) {
|
|
||||||
const ids = bgdSupportedIds();
|
|
||||||
const decoders = bookmark.decoders ?? [];
|
|
||||||
const explicit = decoders.map(function(item) {
|
|
||||||
return item.trim().toLowerCase();
|
|
||||||
}).filter(function(item, index, arr) {
|
|
||||||
return ids.indexOf(item) >= 0 && arr.indexOf(item) === index;
|
|
||||||
});
|
|
||||||
if (explicit.length > 0) return explicit;
|
|
||||||
const mode = bookmark.mode.trim().toUpperCase();
|
|
||||||
return (bgdWindow.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" || bgdWindow.authEnabled === false;
|
|
||||||
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) {
|
|
||||||
const 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) {
|
|
||||||
const row = document.createElement("label");
|
|
||||||
row.className = "bgd-checklist-row";
|
|
||||||
const decoders = bookmarkDecoderKinds(bookmark);
|
|
||||||
const 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.currentTarget.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 ?? false,
|
|
||||||
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.", false);
|
|
||||||
}).catch(function(err) {
|
|
||||||
showToast(`Save failed: ${errorMessage(err)}`, true);
|
|
||||||
}).finally(function() {
|
|
||||||
if (btn) btn.disabled = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
async function resetBackgroundDecode() {
|
|
||||||
const rigId = currentRigId;
|
|
||||||
if (!rigId) return;
|
|
||||||
if (!await bgdWindow.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.", false);
|
|
||||||
}).catch(function(err) {
|
|
||||||
showToast(`Reset failed: ${errorMessage(err)}`, 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 = status.entries ?? [];
|
|
||||||
if (!entries.length) {
|
|
||||||
card.textContent = "No background decode bookmarks configured.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const summary = [];
|
|
||||||
if (status.active_rig) {
|
|
||||||
if (typeof status.center_hz === "number" && Number.isFinite(status.center_hz)) summary.push("Center " + formatFreq(status.center_hz));
|
|
||||||
if (typeof status.sample_rate === "number" && 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 (typeof entry.freq_hz === "number" && 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 `${String(hz)} Hz`;
|
|
||||||
}
|
|
||||||
function escHtml(value) {
|
|
||||||
const text = typeof value === "string" || typeof value === "number" || typeof value === "boolean" ? String(value) : "";
|
|
||||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
||||||
}
|
|
||||||
function errorMessage(error) {
|
|
||||||
return error instanceof Error ? error.message : String(error);
|
|
||||||
}
|
|
||||||
function markBgdDirty() {
|
|
||||||
if (bgdDirty) return;
|
|
||||||
bgdDirty = true;
|
|
||||||
const btn = document.getElementById("background-decode-save-btn");
|
|
||||||
if (btn) btn.classList.add("sch-dirty");
|
|
||||||
}
|
|
||||||
function clearBgdDirty() {
|
|
||||||
bgdDirty = false;
|
|
||||||
const 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: [] };
|
|
||||||
}
|
|
||||||
const 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", () => {
|
|
||||||
void resetBackgroundDecode();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
bgdWindow.initBackgroundDecode = initBackgroundDecode;
|
|
||||||
bgdWindow.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents;
|
|
||||||
bgdWindow.setBackgroundDecodeRig = setBackgroundDecodeRig;
|
|
||||||
})();
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,112 @@
|
|||||||
|
// src/plugins/aprs-shared.ts
|
||||||
|
function aprsPacketCategory(packet) {
|
||||||
|
const type = (packet.type ?? "").toLowerCase();
|
||||||
|
const info = (packet.info ?? "").toLowerCase();
|
||||||
|
if (packet.lat != null && packet.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(timestampMs) {
|
||||||
|
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
||||||
|
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
||||||
|
if (seconds < 5) return "just now";
|
||||||
|
if (seconds < 60) return `${String(seconds)}s ago`;
|
||||||
|
const minutes = Math.round(seconds / 60);
|
||||||
|
if (minutes < 60) return `${String(minutes)}m ago`;
|
||||||
|
return `${String(Math.round(minutes / 60))}h ago`;
|
||||||
|
}
|
||||||
|
function aprsPacketSignature(packet) {
|
||||||
|
return [
|
||||||
|
packet.srcCall ?? "",
|
||||||
|
packet.destCall ?? "",
|
||||||
|
packet.path ?? "",
|
||||||
|
packet.info ?? "",
|
||||||
|
packet.type ?? "",
|
||||||
|
packet.lat?.toFixed(4) ?? "",
|
||||||
|
packet.lon?.toFixed(4) ?? ""
|
||||||
|
].join("|");
|
||||||
|
}
|
||||||
|
function collapseAprsDuplicates(packets) {
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
return packets.filter((packet) => {
|
||||||
|
const signature = aprsPacketSignature(packet);
|
||||||
|
if (seen.has(signature)) return false;
|
||||||
|
seen.add(signature);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function aprsHexBytes(bytes) {
|
||||||
|
if (!bytes?.length) return "--";
|
||||||
|
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
||||||
|
}
|
||||||
|
function renderAprsInfo(packet) {
|
||||||
|
if (packet.info_bytes?.length) {
|
||||||
|
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
||||||
|
}
|
||||||
|
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
||||||
|
}
|
||||||
|
function renderAprsByte(byte) {
|
||||||
|
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function renderAprsCharacter(character) {
|
||||||
|
const code = character.charCodeAt(0);
|
||||||
|
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
||||||
|
}
|
||||||
|
function escapeAprsCharacter(character) {
|
||||||
|
if (character === "<") return "<";
|
||||||
|
if (character === ">") return ">";
|
||||||
|
if (character === "&") return "&";
|
||||||
|
if (character === '"') return """;
|
||||||
|
return character;
|
||||||
|
}
|
||||||
|
function renderLocalAprsSymbol(packet, escapeHtml) {
|
||||||
|
if (!packet.symbolTable || !packet.symbolCode) return "";
|
||||||
|
const symbol = escapeHtml(packet.symbolCode);
|
||||||
|
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
||||||
|
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
||||||
|
}
|
||||||
|
function normalizeAprsPacket(packet, receiver) {
|
||||||
|
return {
|
||||||
|
rig_id: packet.rig_id || null,
|
||||||
|
receiver,
|
||||||
|
srcCall: packet.src_call ?? "",
|
||||||
|
destCall: packet.dest_call ?? "",
|
||||||
|
path: packet.path ?? "",
|
||||||
|
info: packet.info ?? "",
|
||||||
|
info_bytes: packet.info_bytes ?? [],
|
||||||
|
type: packet.packet_type ?? "",
|
||||||
|
crcOk: packet.crc_ok ?? false,
|
||||||
|
ts_ms: packet.ts_ms ?? null,
|
||||||
|
lat: packet.lat ?? null,
|
||||||
|
lon: packet.lon ?? null,
|
||||||
|
symbolTable: packet.symbol_table ?? null,
|
||||||
|
symbolCode: packet.symbol_code ?? null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
aprsPacketCategory,
|
||||||
|
aprsCategoryLabel,
|
||||||
|
aprsAgeText,
|
||||||
|
collapseAprsDuplicates,
|
||||||
|
aprsHexBytes,
|
||||||
|
renderAprsInfo,
|
||||||
|
renderLocalAprsSymbol,
|
||||||
|
normalizeAprsPacket
|
||||||
|
};
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
// src/plugins/ftx-family.ts
|
||||||
|
var bridge = window;
|
||||||
|
function finiteNumber(value) {
|
||||||
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
|
return Number.isFinite(number) ? number : null;
|
||||||
|
}
|
||||||
|
function isAlphaNumeric(value) {
|
||||||
|
return value !== void 0 && /[A-Za-z0-9]/.test(value);
|
||||||
|
}
|
||||||
|
function isGrid(value) {
|
||||||
|
const normalized = value.trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
||||||
|
}
|
||||||
|
function escapeFtxHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function extractFtxGrids(message) {
|
||||||
|
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
||||||
|
}
|
||||||
|
function tokenize(message) {
|
||||||
|
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
||||||
|
}
|
||||||
|
function isCallsign(token) {
|
||||||
|
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
||||||
|
}
|
||||||
|
function extractFtxLocatorDetails(message) {
|
||||||
|
const tokens = tokenize(message);
|
||||||
|
const grids = extractFtxGrids(message);
|
||||||
|
const gridIndex = tokens.findIndex(isGrid);
|
||||||
|
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
|
||||||
|
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
|
||||||
|
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
|
||||||
|
const target = directed ? callsigns[0] ?? null : null;
|
||||||
|
return grids.map((grid) => ({ grid, station: source, source, target }));
|
||||||
|
}
|
||||||
|
function extractFtxCallsign(message) {
|
||||||
|
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
||||||
|
}
|
||||||
|
function renderFtxMessage(message) {
|
||||||
|
let html = "";
|
||||||
|
let index = 0;
|
||||||
|
while (index < message.length) {
|
||||||
|
if (!isAlphaNumeric(message[index])) {
|
||||||
|
html += escapeFtxHtml(message[index] ?? "");
|
||||||
|
index += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let end = index + 1;
|
||||||
|
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
|
||||||
|
const token = message.slice(index, end);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
|
||||||
|
index = end;
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
function installFtxCompatibilityHelpers() {
|
||||||
|
bridge.renderFt8Message = renderFtxMessage;
|
||||||
|
bridge.ft8EscapeHtml = escapeFtxHtml;
|
||||||
|
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
|
||||||
|
bridge.ft8ExtractAllGrids = extractFtxGrids;
|
||||||
|
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
|
||||||
|
}
|
||||||
|
function initializeFt8FamilyBar() {
|
||||||
|
const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
|
||||||
|
const builders = {};
|
||||||
|
const dismissed = { ft8: 0, ft4: 0, ft2: 0 };
|
||||||
|
const overlay = document.getElementById("ft8-bar-overlay");
|
||||||
|
let active = "ft8";
|
||||||
|
const update = () => {
|
||||||
|
if (!overlay) return;
|
||||||
|
const mode = (document.getElementById("mode")?.value ?? "").toUpperCase();
|
||||||
|
const result = builders[active]?.();
|
||||||
|
if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
|
||||||
|
overlay.style.display = "none";
|
||||||
|
overlay.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const label = labels[active];
|
||||||
|
overlay.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}`;
|
||||||
|
overlay.style.display = "flex";
|
||||||
|
};
|
||||||
|
bridge.registerFt8FamilyBarRenderer = (decoder, builder) => {
|
||||||
|
builders[decoder] = builder;
|
||||||
|
};
|
||||||
|
bridge.setFt8FamilyBarDecoder = (decoder) => {
|
||||||
|
active = decoder;
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
bridge.updateFt8Bar = update;
|
||||||
|
bridge.clearFt8Bar = () => {
|
||||||
|
bridge.trxPluginRuntime.reset(active);
|
||||||
|
};
|
||||||
|
bridge.closeFt8Bar = () => {
|
||||||
|
dismissed[active] = Date.now();
|
||||||
|
update();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function initializeFtxDecoder(config) {
|
||||||
|
const { id, label, periodMs, periodDigits = 1 } = config;
|
||||||
|
const status = document.getElementById(`${id}-status`);
|
||||||
|
const period = document.getElementById(`${id}-period`);
|
||||||
|
const messagesElement = document.getElementById(`${id}-messages`);
|
||||||
|
const filterInput = document.getElementById(`${id}-filter`);
|
||||||
|
let filterText = "";
|
||||||
|
let history = [];
|
||||||
|
const renderMessage = (message) => {
|
||||||
|
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
||||||
|
};
|
||||||
|
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
|
||||||
|
const prune = () => {
|
||||||
|
const cutoff = Date.now() - retentionMs();
|
||||||
|
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
||||||
|
};
|
||||||
|
const schedule = (job) => {
|
||||||
|
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
||||||
|
else job();
|
||||||
|
};
|
||||||
|
const displayFrequency = (value) => {
|
||||||
|
const raw = finiteNumber(value);
|
||||||
|
if (raw === null) return null;
|
||||||
|
const base = finiteNumber(bridge.ft8BaseHz);
|
||||||
|
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
|
||||||
|
};
|
||||||
|
const renderRow = (message) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
const raw = message.message ?? "";
|
||||||
|
row.dataset.message = raw.toUpperCase();
|
||||||
|
row.dataset.decoder = id;
|
||||||
|
const storedFrequency = finiteNumber(message.freq_hz);
|
||||||
|
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
|
||||||
|
const snr = finiteNumber(message.snr_db);
|
||||||
|
const delta = finiteNumber(message.dt_s);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
|
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit"
|
||||||
|
});
|
||||||
|
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
||||||
|
return row;
|
||||||
|
};
|
||||||
|
const render = () => {
|
||||||
|
prune();
|
||||||
|
if (!messagesElement) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let count = 0;
|
||||||
|
for (const message of history) {
|
||||||
|
if (count >= 200) break;
|
||||||
|
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
||||||
|
fragment.appendChild(renderRow(message));
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
messagesElement.replaceChildren(fragment);
|
||||||
|
};
|
||||||
|
const normalize = (message) => {
|
||||||
|
const raw = message.message ?? "";
|
||||||
|
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
||||||
|
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
||||||
|
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
if (grids.length > 0) {
|
||||||
|
bridge.mapAddLocator?.(raw, grids, id, station, {
|
||||||
|
...message,
|
||||||
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
|
locator_details: locatorDetails
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
||||||
|
ts_ms: message.ts_ms,
|
||||||
|
snr_db: message.snr_db,
|
||||||
|
dt_s: message.dt_s,
|
||||||
|
freq_hz: frequency ?? message.freq_hz,
|
||||||
|
message: message.message,
|
||||||
|
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const receiveBatch = (messages) => {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
if (status) status.textContent = "Receiving";
|
||||||
|
history = messages.map(normalize).reverse().concat(history);
|
||||||
|
prune();
|
||||||
|
bridge.setFt8FamilyBarDecoder?.(id);
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
schedule(render);
|
||||||
|
};
|
||||||
|
const reset = () => {
|
||||||
|
history = [];
|
||||||
|
bridge.updateFt8Bar?.();
|
||||||
|
render();
|
||||||
|
bridge.clearMapMarkersByType?.(id);
|
||||||
|
};
|
||||||
|
const barFrames = () => {
|
||||||
|
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
||||||
|
let html = "";
|
||||||
|
for (const message of recent) {
|
||||||
|
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
||||||
|
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
||||||
|
const snr = finiteNumber(message.snr_db);
|
||||||
|
const delta = finiteNumber(message.dt_s);
|
||||||
|
const frequency = displayFrequency(message.freq_hz);
|
||||||
|
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
||||||
|
}
|
||||||
|
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
|
||||||
|
};
|
||||||
|
bridge.trxPluginRuntime.registerDecoder({
|
||||||
|
id,
|
||||||
|
onMessage: (message) => {
|
||||||
|
receiveBatch([message]);
|
||||||
|
},
|
||||||
|
onBatch: receiveBatch,
|
||||||
|
restore: receiveBatch,
|
||||||
|
prune: () => {
|
||||||
|
prune();
|
||||||
|
render();
|
||||||
|
},
|
||||||
|
reset
|
||||||
|
});
|
||||||
|
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
||||||
|
const updatePeriod = () => {
|
||||||
|
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
|
||||||
|
};
|
||||||
|
updatePeriod();
|
||||||
|
window.setInterval(updatePeriod, 250);
|
||||||
|
filterInput?.addEventListener("input", () => {
|
||||||
|
filterText = filterInput.value.trim().toUpperCase();
|
||||||
|
render();
|
||||||
|
});
|
||||||
|
messagesElement?.addEventListener("click", (event) => {
|
||||||
|
if (!(event.target instanceof Element)) return;
|
||||||
|
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
|
||||||
|
if (grid) {
|
||||||
|
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
|
||||||
|
toggle?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
||||||
|
await bridge.postPath?.(`/toggle_${id}_decode`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${label} toggle failed`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await bridge.postPath?.(`/clear_${id}_decode`);
|
||||||
|
reset();
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${label} history clear failed`, error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
installFtxCompatibilityHelpers,
|
||||||
|
initializeFt8FamilyBar,
|
||||||
|
initializeFtxDecoder
|
||||||
|
};
|
||||||
@@ -1,403 +1,400 @@
|
|||||||
"use strict";
|
// src/plugins/cw.ts
|
||||||
(() => {
|
var cwWindow = window;
|
||||||
// src/plugins/cw.ts
|
var cwStatusEl = document.getElementById("cw-status");
|
||||||
var cwWindow = window;
|
var cwOutputEl = document.getElementById("cw-output");
|
||||||
var cwStatusEl = document.getElementById("cw-status");
|
var cwAutoInput = document.getElementById("cw-auto");
|
||||||
var cwOutputEl = document.getElementById("cw-output");
|
var cwWpmInput = document.getElementById("cw-wpm");
|
||||||
var cwAutoInput = document.getElementById("cw-auto");
|
var cwToneInput = document.getElementById("cw-tone");
|
||||||
var cwWpmInput = document.getElementById("cw-wpm");
|
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
||||||
var cwToneInput = document.getElementById("cw-tone");
|
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
||||||
var cwSignalIndicator = document.getElementById("cw-signal-indicator");
|
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
||||||
var cwToneCanvas = document.getElementById("cw-tone-waterfall");
|
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
||||||
var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null;
|
var cwToneRangeEl = document.getElementById("cw-tone-range");
|
||||||
var cwTonePickerEl = document.querySelector(".cw-tone-picker");
|
var cwBarOverlay = document.getElementById("cw-bar-overlay");
|
||||||
var cwToneRangeEl = document.getElementById("cw-tone-range");
|
var CW_MAX_LINES = 200;
|
||||||
var cwBarOverlay = document.getElementById("cw-bar-overlay");
|
var CW_TONE_MIN_HZ = 100;
|
||||||
var CW_MAX_LINES = 200;
|
var CW_TONE_MAX_HZ = 1e4;
|
||||||
var CW_TONE_MIN_HZ = 100;
|
var CW_WPM_MIN = 5;
|
||||||
var CW_TONE_MAX_HZ = 1e4;
|
var CW_WPM_MAX = 40;
|
||||||
var CW_WPM_MIN = 5;
|
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
var CW_WPM_MAX = 40;
|
var CW_BAR_LINE_GAP_MS = 5e3;
|
||||||
var CW_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
var cwLastAppendTime = 0;
|
||||||
var CW_BAR_LINE_GAP_MS = 5e3;
|
var cwTonePickerRaf = null;
|
||||||
var cwLastAppendTime = 0;
|
var cwBarHistory = [];
|
||||||
var cwTonePickerRaf = null;
|
var cwBarCurrentLine = null;
|
||||||
var cwBarHistory = [];
|
var cwBarDismissedAtMs = 0;
|
||||||
var cwBarCurrentLine = null;
|
var cwAutoLocalOverride = null;
|
||||||
var cwBarDismissedAtMs = 0;
|
function escapeCwHtml(input) {
|
||||||
var cwAutoLocalOverride = null;
|
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
function escapeCwHtml(input) {
|
}
|
||||||
return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
function applyCwAutoUi(enabled) {
|
||||||
}
|
if (cwAutoInput) cwAutoInput.checked = enabled;
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
|
||||||
cwWindow.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, 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 ? `${String(line.wpm)} WPM` : null,
|
|
||||||
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
|
||||||
].filter(Boolean).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
|
|
||||||
}
|
|
||||||
cwBarOverlay.innerHTML = html;
|
|
||||||
cwBarOverlay.style.display = "flex";
|
|
||||||
}
|
|
||||||
cwWindow.updateCwBar = updateCwBar;
|
|
||||||
cwWindow.clearCwBar = function() {
|
|
||||||
resetCwHistoryView();
|
|
||||||
};
|
|
||||||
cwWindow.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(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
|
||||||
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
|
||||||
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const mode = (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;
|
|
||||||
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 (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
|
||||||
if (cwToneRangeEl) {
|
|
||||||
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
|
||||||
if (mode !== "CW" && mode !== "CWR") {
|
|
||||||
cwToneRangeEl.textContent = "CW/CWR mode required";
|
|
||||||
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.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 ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
|
||||||
}
|
|
||||||
const bins = cwWindow.lastSpectrumData.bins;
|
|
||||||
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
|
||||||
const centerHz = cwWindow.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] ?? -140;
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
|
||||||
}
|
|
||||||
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 cwWindow.trxParseCssColor === "function" ? cwWindow.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] ?? -140));
|
|
||||||
}
|
|
||||||
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))] ?? -140);
|
|
||||||
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 = String(clamped);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error("CW tone set failed", e);
|
|
||||||
}
|
|
||||||
drawCwTonePicker();
|
|
||||||
}
|
|
||||||
if (cwAutoInput) {
|
|
||||||
cwAutoInput.addEventListener("change", () => {
|
|
||||||
void (async () => {
|
|
||||||
const enabled = cwAutoInput.checked;
|
|
||||||
cwAutoLocalOverride = enabled;
|
|
||||||
applyCwAutoUi(enabled);
|
|
||||||
try {
|
|
||||||
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
|
||||||
drawCwTonePicker();
|
|
||||||
} catch (error) {
|
|
||||||
console.error("CW auto toggle failed", error);
|
|
||||||
} finally {
|
|
||||||
cwAutoLocalOverride = null;
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (cwWpmInput) {
|
if (cwWpmInput) {
|
||||||
cwWpmInput.addEventListener("change", () => {
|
cwWpmInput.disabled = enabled;
|
||||||
void (async () => {
|
cwWpmInput.readOnly = enabled;
|
||||||
if (cwAutoInput?.checked) return;
|
|
||||||
const wpm = clampCwWpm(cwWpmInput.value);
|
|
||||||
cwWpmInput.value = String(wpm);
|
|
||||||
try {
|
|
||||||
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("CW WPM set failed", error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (cwToneInput) {
|
if (cwToneInput) {
|
||||||
cwToneInput.addEventListener("change", () => {
|
cwToneInput.disabled = enabled;
|
||||||
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
cwToneInput.readOnly = enabled;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (cwToneCanvas) {
|
if (cwTonePickerEl) {
|
||||||
cwToneCanvas.addEventListener("click", (event) => {
|
cwTonePickerEl.classList.toggle("is-auto", enabled);
|
||||||
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;
|
|
||||||
void setCwTone(tone);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
function resetCwHistoryView() {
|
}
|
||||||
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
cwWindow.applyCwAutoUi = applyCwAutoUi;
|
||||||
cwLastAppendTime = 0;
|
cwWindow.applyCwAutoUiFromServer = function(enabled) {
|
||||||
cwBarHistory = [];
|
if (cwAutoLocalOverride !== null) return;
|
||||||
cwBarCurrentLine = null;
|
applyCwAutoUi(enabled);
|
||||||
updateCwBar();
|
};
|
||||||
drawCwTonePicker();
|
function cwBarFlushCurrentLine() {
|
||||||
|
if (cwBarCurrentLine && cwBarCurrentLine.text.trim()) {
|
||||||
|
cwBarHistory.unshift(cwBarCurrentLine);
|
||||||
|
if (cwBarHistory.length > 50) cwBarHistory.length = 50;
|
||||||
}
|
}
|
||||||
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
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, 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 ? `${String(line.wpm)} WPM` : null,
|
||||||
|
line.tone_hz ? `${String(line.tone_hz)} Hz` : null
|
||||||
|
].filter(Boolean).join(" · ");
|
||||||
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${escapeCwHtml(line.text)}` + (meta ? ` <span class="aprs-bar-time">${escapeCwHtml(meta)}</span>` : "") + `</div></div>`;
|
||||||
|
}
|
||||||
|
cwBarOverlay.innerHTML = html;
|
||||||
|
cwBarOverlay.style.display = "flex";
|
||||||
|
}
|
||||||
|
cwWindow.updateCwBar = updateCwBar;
|
||||||
|
cwWindow.clearCwBar = function() {
|
||||||
|
resetCwHistoryView();
|
||||||
|
};
|
||||||
|
cwWindow.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(cwWindow.lastFreqHz) ? Number(cwWindow.lastFreqHz) : NaN;
|
||||||
|
const bandwidthHz = Number.isFinite(cwWindow.currentBandwidthHz) ? Number(cwWindow.currentBandwidthHz) : NaN;
|
||||||
|
if (!Number.isFinite(tunedHz) || !Number.isFinite(bandwidthHz) || bandwidthHz <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const mode = (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;
|
||||||
|
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 (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.lastSpectrumData.bins.length || !range) {
|
||||||
|
if (cwToneRangeEl) {
|
||||||
|
const mode = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||||
|
if (mode !== "CW" && mode !== "CWR") {
|
||||||
|
cwToneRangeEl.textContent = "CW/CWR mode required";
|
||||||
|
} else if (!cwWindow.lastSpectrumData || !Array.isArray(cwWindow.lastSpectrumData.bins) || !cwWindow.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 ${String(range.toneMinHz)}-${String(range.toneMaxHz)} Hz · ${side}`;
|
||||||
|
}
|
||||||
|
const bins = cwWindow.lastSpectrumData.bins;
|
||||||
|
const sampleRate = cwWindow.lastSpectrumData.sample_rate;
|
||||||
|
const centerHz = cwWindow.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] ?? -140;
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
smoothed[x] = count > 0 ? sum / count : tones[x] ?? -140;
|
||||||
|
}
|
||||||
|
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 cwWindow.trxParseCssColor === "function" ? cwWindow.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] ?? -140));
|
||||||
|
}
|
||||||
|
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))] ?? -140);
|
||||||
|
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 = String(clamped);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await cwWindow.postPath?.(`/set_cw_tone?tone_hz=${encodeURIComponent(clamped)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("CW tone set failed", e);
|
||||||
|
}
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
if (cwAutoInput) {
|
||||||
|
cwAutoInput.addEventListener("change", () => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
const enabled = cwAutoInput.checked;
|
||||||
|
cwAutoLocalOverride = enabled;
|
||||||
|
applyCwAutoUi(enabled);
|
||||||
try {
|
try {
|
||||||
await cwWindow.postPath?.("/clear_cw_decode");
|
await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`);
|
||||||
resetCwHistoryView();
|
drawCwTonePicker();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("CW history clear failed", error);
|
console.error("CW auto toggle failed", error);
|
||||||
|
} finally {
|
||||||
|
cwAutoLocalOverride = null;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
function onServerCw(evt) {
|
}
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
if (cwWpmInput) {
|
||||||
if (evt.text && cwOutputEl) {
|
cwWpmInput.addEventListener("change", () => {
|
||||||
const now = Date.now();
|
void (async () => {
|
||||||
if (!cwOutputEl.lastElementChild || now - cwLastAppendTime > 1e4 || evt.text === "\n") {
|
if (cwAutoInput?.checked) return;
|
||||||
const line = document.createElement("div");
|
const wpm = clampCwWpm(cwWpmInput.value);
|
||||||
line.className = "cw-line";
|
cwWpmInput.value = String(wpm);
|
||||||
cwOutputEl.appendChild(line);
|
try {
|
||||||
|
await cwWindow.postPath?.(`/set_cw_wpm?wpm=${encodeURIComponent(wpm)}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW WPM set failed", error);
|
||||||
}
|
}
|
||||||
cwLastAppendTime = now;
|
})();
|
||||||
const lastLine = cwOutputEl.lastElementChild;
|
|
||||||
if (lastLine) {
|
|
||||||
lastLine.textContent += evt.text;
|
|
||||||
}
|
|
||||||
while (cwOutputEl.children.length > CW_MAX_LINES) {
|
|
||||||
const firstChild = cwOutputEl.firstChild;
|
|
||||||
if (!firstChild) break;
|
|
||||||
cwOutputEl.removeChild(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 = String(clampCwWpm(evt.wpm));
|
|
||||||
}
|
|
||||||
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
|
||||||
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cwTonePickerRaf != null) return;
|
|
||||||
cwTonePickerRaf = requestAnimationFrame(() => {
|
|
||||||
cwTonePickerRaf = null;
|
|
||||||
drawCwTonePicker();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function restoreCwHistory(events) {
|
|
||||||
if (!Array.isArray(events) || events.length === 0) return;
|
|
||||||
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
|
||||||
for (const evt of events) {
|
|
||||||
onServerCw(evt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cwWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "cw",
|
|
||||||
onMessage: onServerCw,
|
|
||||||
restore: restoreCwHistory,
|
|
||||||
reset: resetCwHistoryView
|
|
||||||
});
|
});
|
||||||
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
}
|
||||||
ensureCwToneCanvasResolution();
|
if (cwToneInput) {
|
||||||
drawCwTonePicker();
|
cwToneInput.addEventListener("change", () => {
|
||||||
};
|
if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value);
|
||||||
window.addEventListener("resize", () => {
|
|
||||||
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
|
||||||
});
|
});
|
||||||
applyCwAutoUi(!!cwAutoInput?.checked);
|
}
|
||||||
|
if (cwToneCanvas) {
|
||||||
|
cwToneCanvas.addEventListener("click", (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;
|
||||||
|
void setCwTone(tone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function resetCwHistoryView() {
|
||||||
|
if (cwOutputEl) cwOutputEl.innerHTML = "";
|
||||||
|
cwLastAppendTime = 0;
|
||||||
|
cwBarHistory = [];
|
||||||
|
cwBarCurrentLine = null;
|
||||||
updateCwBar();
|
updateCwBar();
|
||||||
|
drawCwTonePicker();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-cw-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await cwWindow.trxUi.confirm({ title: "Clear CW history?", message: "All stored CW decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await cwWindow.postPath?.("/clear_cw_decode");
|
||||||
|
resetCwHistoryView();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("CW history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
function onServerCw(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) {
|
||||||
|
const firstChild = cwOutputEl.firstChild;
|
||||||
|
if (!firstChild) break;
|
||||||
|
cwOutputEl.removeChild(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 = String(clampCwWpm(evt.wpm));
|
||||||
|
}
|
||||||
|
if (cwToneInput && Number.isFinite(Number(evt.tone_hz))) {
|
||||||
|
cwToneInput.value = String(toneClampForRange(evt.tone_hz, currentCwToneRange()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cwTonePickerRaf != null) return;
|
||||||
|
cwTonePickerRaf = requestAnimationFrame(() => {
|
||||||
|
cwTonePickerRaf = null;
|
||||||
|
drawCwTonePicker();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function restoreCwHistory(events) {
|
||||||
|
if (!Array.isArray(events) || events.length === 0) return;
|
||||||
|
if (cwStatusEl) cwStatusEl.textContent = "Receiving";
|
||||||
|
for (const evt of events) {
|
||||||
|
onServerCw(evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cwWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "cw",
|
||||||
|
onMessage: onServerCw,
|
||||||
|
restore: restoreCwHistory,
|
||||||
|
reset: resetCwHistoryView
|
||||||
|
});
|
||||||
|
cwWindow.refreshCwTonePicker = function refreshCwTonePicker() {
|
||||||
ensureCwToneCanvasResolution();
|
ensureCwToneCanvasResolution();
|
||||||
drawCwTonePicker();
|
drawCwTonePicker();
|
||||||
})();
|
};
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (ensureCwToneCanvasResolution()) drawCwTonePicker();
|
||||||
|
});
|
||||||
|
applyCwAutoUi(!!cwAutoInput?.checked);
|
||||||
|
updateCwBar();
|
||||||
|
ensureCwToneCanvasResolution();
|
||||||
|
drawCwTonePicker();
|
||||||
|
|||||||
@@ -1,227 +1,6 @@
|
|||||||
"use strict";
|
import {
|
||||||
(() => {
|
initializeFtxDecoder
|
||||||
// src/plugins/ftx-family.ts
|
} from "./chunk-SGMG5LG2.js";
|
||||||
var bridge = window;
|
|
||||||
function finiteNumber(value) {
|
|
||||||
const number = typeof value === "number" ? value : Number(value);
|
|
||||||
return Number.isFinite(number) ? number : null;
|
|
||||||
}
|
|
||||||
function isAlphaNumeric(value) {
|
|
||||||
return value !== void 0 && /[A-Za-z0-9]/.test(value);
|
|
||||||
}
|
|
||||||
function isGrid(value) {
|
|
||||||
const normalized = value.trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
|
||||||
}
|
|
||||||
function escapeFtxHtml(input) {
|
|
||||||
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
||||||
}
|
|
||||||
function extractFtxGrids(message) {
|
|
||||||
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
|
||||||
}
|
|
||||||
function tokenize(message) {
|
|
||||||
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
function isCallsign(token) {
|
|
||||||
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
|
||||||
}
|
|
||||||
function extractFtxLocatorDetails(message) {
|
|
||||||
const tokens = tokenize(message);
|
|
||||||
const grids = extractFtxGrids(message);
|
|
||||||
const gridIndex = tokens.findIndex(isGrid);
|
|
||||||
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
|
|
||||||
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
|
|
||||||
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
|
|
||||||
const target = directed ? callsigns[0] ?? null : null;
|
|
||||||
return grids.map((grid) => ({ grid, station: source, source, target }));
|
|
||||||
}
|
|
||||||
function extractFtxCallsign(message) {
|
|
||||||
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
|
||||||
}
|
|
||||||
function renderFtxMessage(message) {
|
|
||||||
let html = "";
|
|
||||||
let index = 0;
|
|
||||||
while (index < message.length) {
|
|
||||||
if (!isAlphaNumeric(message[index])) {
|
|
||||||
html += escapeFtxHtml(message[index] ?? "");
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let end = index + 1;
|
|
||||||
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
|
|
||||||
const token = message.slice(index, end);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
|
|
||||||
index = end;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
function initializeFtxDecoder(config) {
|
|
||||||
const { id, label, periodMs, periodDigits = 1 } = config;
|
|
||||||
const status = document.getElementById(`${id}-status`);
|
|
||||||
const period = document.getElementById(`${id}-period`);
|
|
||||||
const messagesElement = document.getElementById(`${id}-messages`);
|
|
||||||
const filterInput = document.getElementById(`${id}-filter`);
|
|
||||||
let filterText = "";
|
|
||||||
let history = [];
|
|
||||||
const renderMessage = (message) => {
|
|
||||||
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
|
||||||
};
|
|
||||||
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
|
|
||||||
const prune = () => {
|
|
||||||
const cutoff = Date.now() - retentionMs();
|
|
||||||
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
|
||||||
};
|
|
||||||
const schedule = (job) => {
|
|
||||||
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
|
||||||
else job();
|
|
||||||
};
|
|
||||||
const displayFrequency = (value) => {
|
|
||||||
const raw = finiteNumber(value);
|
|
||||||
if (raw === null) return null;
|
|
||||||
const base = finiteNumber(bridge.ft8BaseHz);
|
|
||||||
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
|
|
||||||
};
|
|
||||||
const renderRow = (message) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const raw = message.message ?? "";
|
|
||||||
row.dataset.message = raw.toUpperCase();
|
|
||||||
row.dataset.decoder = id;
|
|
||||||
const storedFrequency = finiteNumber(message.freq_hz);
|
|
||||||
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
|
|
||||||
const snr = finiteNumber(message.snr_db);
|
|
||||||
const delta = finiteNumber(message.dt_s);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit"
|
|
||||||
});
|
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
const render = () => {
|
|
||||||
prune();
|
|
||||||
if (!messagesElement) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let count = 0;
|
|
||||||
for (const message of history) {
|
|
||||||
if (count >= 200) break;
|
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
|
||||||
fragment.appendChild(renderRow(message));
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
messagesElement.replaceChildren(fragment);
|
|
||||||
};
|
|
||||||
const normalize = (message) => {
|
|
||||||
const raw = message.message ?? "";
|
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
if (grids.length > 0) {
|
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
|
||||||
...message,
|
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
|
||||||
locator_details: locatorDetails
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
|
||||||
ts_ms: message.ts_ms,
|
|
||||||
snr_db: message.snr_db,
|
|
||||||
dt_s: message.dt_s,
|
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
|
||||||
message: message.message,
|
|
||||||
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const receiveBatch = (messages) => {
|
|
||||||
if (messages.length === 0) return;
|
|
||||||
if (status) status.textContent = "Receiving";
|
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
|
||||||
prune();
|
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
schedule(render);
|
|
||||||
};
|
|
||||||
const reset = () => {
|
|
||||||
history = [];
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
render();
|
|
||||||
bridge.clearMapMarkersByType?.(id);
|
|
||||||
};
|
|
||||||
const barFrames = () => {
|
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
|
||||||
let html = "";
|
|
||||||
for (const message of recent) {
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
|
||||||
const snr = finiteNumber(message.snr_db);
|
|
||||||
const delta = finiteNumber(message.dt_s);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
|
|
||||||
};
|
|
||||||
bridge.trxPluginRuntime.registerDecoder({
|
|
||||||
id,
|
|
||||||
onMessage: (message) => {
|
|
||||||
receiveBatch([message]);
|
|
||||||
},
|
|
||||||
onBatch: receiveBatch,
|
|
||||||
restore: receiveBatch,
|
|
||||||
prune: () => {
|
|
||||||
prune();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
reset
|
|
||||||
});
|
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
|
||||||
const updatePeriod = () => {
|
|
||||||
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
|
|
||||||
};
|
|
||||||
updatePeriod();
|
|
||||||
window.setInterval(updatePeriod, 250);
|
|
||||||
filterInput?.addEventListener("input", () => {
|
|
||||||
filterText = filterInput.value.trim().toUpperCase();
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
messagesElement?.addEventListener("click", (event) => {
|
|
||||||
if (!(event.target instanceof Element)) return;
|
|
||||||
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
|
|
||||||
if (grid) {
|
|
||||||
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
|
|
||||||
toggle?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
|
||||||
await bridge.postPath?.(`/toggle_${id}_decode`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} toggle failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await bridge.postPath?.(`/clear_${id}_decode`);
|
|
||||||
reset();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} history clear failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/plugins/ft2.ts
|
// src/plugins/ft2.ts
|
||||||
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
|
initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 });
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,227 +1,6 @@
|
|||||||
"use strict";
|
import {
|
||||||
(() => {
|
initializeFtxDecoder
|
||||||
// src/plugins/ftx-family.ts
|
} from "./chunk-SGMG5LG2.js";
|
||||||
var bridge = window;
|
|
||||||
function finiteNumber(value) {
|
|
||||||
const number = typeof value === "number" ? value : Number(value);
|
|
||||||
return Number.isFinite(number) ? number : null;
|
|
||||||
}
|
|
||||||
function isAlphaNumeric(value) {
|
|
||||||
return value !== void 0 && /[A-Za-z0-9]/.test(value);
|
|
||||||
}
|
|
||||||
function isGrid(value) {
|
|
||||||
const normalized = value.trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
|
||||||
}
|
|
||||||
function escapeFtxHtml(input) {
|
|
||||||
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
||||||
}
|
|
||||||
function extractFtxGrids(message) {
|
|
||||||
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
|
||||||
}
|
|
||||||
function tokenize(message) {
|
|
||||||
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
function isCallsign(token) {
|
|
||||||
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
|
||||||
}
|
|
||||||
function extractFtxLocatorDetails(message) {
|
|
||||||
const tokens = tokenize(message);
|
|
||||||
const grids = extractFtxGrids(message);
|
|
||||||
const gridIndex = tokens.findIndex(isGrid);
|
|
||||||
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
|
|
||||||
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
|
|
||||||
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
|
|
||||||
const target = directed ? callsigns[0] ?? null : null;
|
|
||||||
return grids.map((grid) => ({ grid, station: source, source, target }));
|
|
||||||
}
|
|
||||||
function extractFtxCallsign(message) {
|
|
||||||
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
|
||||||
}
|
|
||||||
function renderFtxMessage(message) {
|
|
||||||
let html = "";
|
|
||||||
let index = 0;
|
|
||||||
while (index < message.length) {
|
|
||||||
if (!isAlphaNumeric(message[index])) {
|
|
||||||
html += escapeFtxHtml(message[index] ?? "");
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let end = index + 1;
|
|
||||||
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
|
|
||||||
const token = message.slice(index, end);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
|
|
||||||
index = end;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
function initializeFtxDecoder(config) {
|
|
||||||
const { id, label, periodMs, periodDigits = 1 } = config;
|
|
||||||
const status = document.getElementById(`${id}-status`);
|
|
||||||
const period = document.getElementById(`${id}-period`);
|
|
||||||
const messagesElement = document.getElementById(`${id}-messages`);
|
|
||||||
const filterInput = document.getElementById(`${id}-filter`);
|
|
||||||
let filterText = "";
|
|
||||||
let history = [];
|
|
||||||
const renderMessage = (message) => {
|
|
||||||
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
|
||||||
};
|
|
||||||
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
|
|
||||||
const prune = () => {
|
|
||||||
const cutoff = Date.now() - retentionMs();
|
|
||||||
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
|
||||||
};
|
|
||||||
const schedule = (job) => {
|
|
||||||
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
|
||||||
else job();
|
|
||||||
};
|
|
||||||
const displayFrequency = (value) => {
|
|
||||||
const raw = finiteNumber(value);
|
|
||||||
if (raw === null) return null;
|
|
||||||
const base = finiteNumber(bridge.ft8BaseHz);
|
|
||||||
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
|
|
||||||
};
|
|
||||||
const renderRow = (message) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const raw = message.message ?? "";
|
|
||||||
row.dataset.message = raw.toUpperCase();
|
|
||||||
row.dataset.decoder = id;
|
|
||||||
const storedFrequency = finiteNumber(message.freq_hz);
|
|
||||||
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
|
|
||||||
const snr = finiteNumber(message.snr_db);
|
|
||||||
const delta = finiteNumber(message.dt_s);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit"
|
|
||||||
});
|
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
const render = () => {
|
|
||||||
prune();
|
|
||||||
if (!messagesElement) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let count = 0;
|
|
||||||
for (const message of history) {
|
|
||||||
if (count >= 200) break;
|
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
|
||||||
fragment.appendChild(renderRow(message));
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
messagesElement.replaceChildren(fragment);
|
|
||||||
};
|
|
||||||
const normalize = (message) => {
|
|
||||||
const raw = message.message ?? "";
|
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
if (grids.length > 0) {
|
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
|
||||||
...message,
|
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
|
||||||
locator_details: locatorDetails
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
|
||||||
ts_ms: message.ts_ms,
|
|
||||||
snr_db: message.snr_db,
|
|
||||||
dt_s: message.dt_s,
|
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
|
||||||
message: message.message,
|
|
||||||
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const receiveBatch = (messages) => {
|
|
||||||
if (messages.length === 0) return;
|
|
||||||
if (status) status.textContent = "Receiving";
|
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
|
||||||
prune();
|
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
schedule(render);
|
|
||||||
};
|
|
||||||
const reset = () => {
|
|
||||||
history = [];
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
render();
|
|
||||||
bridge.clearMapMarkersByType?.(id);
|
|
||||||
};
|
|
||||||
const barFrames = () => {
|
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
|
||||||
let html = "";
|
|
||||||
for (const message of recent) {
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
|
||||||
const snr = finiteNumber(message.snr_db);
|
|
||||||
const delta = finiteNumber(message.dt_s);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
|
|
||||||
};
|
|
||||||
bridge.trxPluginRuntime.registerDecoder({
|
|
||||||
id,
|
|
||||||
onMessage: (message) => {
|
|
||||||
receiveBatch([message]);
|
|
||||||
},
|
|
||||||
onBatch: receiveBatch,
|
|
||||||
restore: receiveBatch,
|
|
||||||
prune: () => {
|
|
||||||
prune();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
reset
|
|
||||||
});
|
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
|
||||||
const updatePeriod = () => {
|
|
||||||
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
|
|
||||||
};
|
|
||||||
updatePeriod();
|
|
||||||
window.setInterval(updatePeriod, 250);
|
|
||||||
filterInput?.addEventListener("input", () => {
|
|
||||||
filterText = filterInput.value.trim().toUpperCase();
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
messagesElement?.addEventListener("click", (event) => {
|
|
||||||
if (!(event.target instanceof Element)) return;
|
|
||||||
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
|
|
||||||
if (grid) {
|
|
||||||
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
|
|
||||||
toggle?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
|
||||||
await bridge.postPath?.(`/toggle_${id}_decode`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} toggle failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await bridge.postPath?.(`/clear_${id}_decode`);
|
|
||||||
reset();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} history clear failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/plugins/ft4.ts
|
// src/plugins/ft4.ts
|
||||||
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
|
initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 });
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,271 +1,10 @@
|
|||||||
"use strict";
|
import {
|
||||||
(() => {
|
initializeFt8FamilyBar,
|
||||||
// src/plugins/ftx-family.ts
|
initializeFtxDecoder,
|
||||||
var bridge = window;
|
installFtxCompatibilityHelpers
|
||||||
function finiteNumber(value) {
|
} from "./chunk-SGMG5LG2.js";
|
||||||
const number = typeof value === "number" ? value : Number(value);
|
|
||||||
return Number.isFinite(number) ? number : null;
|
|
||||||
}
|
|
||||||
function isAlphaNumeric(value) {
|
|
||||||
return value !== void 0 && /[A-Za-z0-9]/.test(value);
|
|
||||||
}
|
|
||||||
function isGrid(value) {
|
|
||||||
const normalized = value.trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && normalized !== "RR73" && normalized !== "73" && normalized !== "RR";
|
|
||||||
}
|
|
||||||
function escapeFtxHtml(input) {
|
|
||||||
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
||||||
}
|
|
||||||
function extractFtxGrids(message) {
|
|
||||||
return [...new Set(message.toUpperCase().split(/[^A-Z0-9]+/).filter(isGrid))];
|
|
||||||
}
|
|
||||||
function tokenize(message) {
|
|
||||||
return message.toUpperCase().split(/[^A-Z0-9/]+/).filter(Boolean);
|
|
||||||
}
|
|
||||||
function isCallsign(token) {
|
|
||||||
return token !== void 0 && token.length >= 3 && token.length <= 12 && !["CQ", "DE", "QRZ", "DX"].includes(token) && !isGrid(token) && /^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token);
|
|
||||||
}
|
|
||||||
function extractFtxLocatorDetails(message) {
|
|
||||||
const tokens = tokenize(message);
|
|
||||||
const grids = extractFtxGrids(message);
|
|
||||||
const gridIndex = tokens.findIndex(isGrid);
|
|
||||||
const callsigns = tokens.slice(0, gridIndex < 0 ? tokens.length : gridIndex).filter(isCallsign);
|
|
||||||
const directed = callsigns.length >= 2 && !["CQ", "DE", "QRZ"].includes(tokens[0] ?? "");
|
|
||||||
const source = directed ? callsigns[1] ?? null : callsigns[0] ?? null;
|
|
||||||
const target = directed ? callsigns[0] ?? null : null;
|
|
||||||
return grids.map((grid) => ({ grid, station: source, source, target }));
|
|
||||||
}
|
|
||||||
function extractFtxCallsign(message) {
|
|
||||||
return extractFtxLocatorDetails(message)[0]?.station ?? tokenize(message).find(isCallsign) ?? null;
|
|
||||||
}
|
|
||||||
function renderFtxMessage(message) {
|
|
||||||
let html = "";
|
|
||||||
let index = 0;
|
|
||||||
while (index < message.length) {
|
|
||||||
if (!isAlphaNumeric(message[index])) {
|
|
||||||
html += escapeFtxHtml(message[index] ?? "");
|
|
||||||
index += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let end = index + 1;
|
|
||||||
while (end < message.length && isAlphaNumeric(message[end])) end += 1;
|
|
||||||
const token = message.slice(index, end);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
html += isGrid(grid) ? `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>` : escapeFtxHtml(token);
|
|
||||||
index = end;
|
|
||||||
}
|
|
||||||
return html;
|
|
||||||
}
|
|
||||||
function installFtxCompatibilityHelpers() {
|
|
||||||
bridge.renderFt8Message = renderFtxMessage;
|
|
||||||
bridge.ft8EscapeHtml = escapeFtxHtml;
|
|
||||||
bridge.ft8ExtractLocatorDetails = extractFtxLocatorDetails;
|
|
||||||
bridge.ft8ExtractAllGrids = extractFtxGrids;
|
|
||||||
bridge.ft8ExtractLikelyCallsign = extractFtxCallsign;
|
|
||||||
}
|
|
||||||
function initializeFt8FamilyBar() {
|
|
||||||
const labels = { ft8: "FT8", ft4: "FT4", ft2: "FT2" };
|
|
||||||
const builders = {};
|
|
||||||
const dismissed = { ft8: 0, ft4: 0, ft2: 0 };
|
|
||||||
const overlay = document.getElementById("ft8-bar-overlay");
|
|
||||||
let active = "ft8";
|
|
||||||
const update = () => {
|
|
||||||
if (!overlay) return;
|
|
||||||
const mode = (document.getElementById("mode")?.value ?? "").toUpperCase();
|
|
||||||
const result = builders[active]?.();
|
|
||||||
if (mode !== "DIG" && mode !== "USB" || !result || result.count === 0 || result.newestTsMs <= dismissed[active]) {
|
|
||||||
overlay.style.display = "none";
|
|
||||||
overlay.innerHTML = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const label = labels[active];
|
|
||||||
overlay.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}`;
|
|
||||||
overlay.style.display = "flex";
|
|
||||||
};
|
|
||||||
bridge.registerFt8FamilyBarRenderer = (decoder, builder) => {
|
|
||||||
builders[decoder] = builder;
|
|
||||||
};
|
|
||||||
bridge.setFt8FamilyBarDecoder = (decoder) => {
|
|
||||||
active = decoder;
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
bridge.updateFt8Bar = update;
|
|
||||||
bridge.clearFt8Bar = () => {
|
|
||||||
bridge.trxPluginRuntime.reset(active);
|
|
||||||
};
|
|
||||||
bridge.closeFt8Bar = () => {
|
|
||||||
dismissed[active] = Date.now();
|
|
||||||
update();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function initializeFtxDecoder(config) {
|
|
||||||
const { id, label, periodMs, periodDigits = 1 } = config;
|
|
||||||
const status = document.getElementById(`${id}-status`);
|
|
||||||
const period = document.getElementById(`${id}-period`);
|
|
||||||
const messagesElement = document.getElementById(`${id}-messages`);
|
|
||||||
const filterInput = document.getElementById(`${id}-filter`);
|
|
||||||
let filterText = "";
|
|
||||||
let history = [];
|
|
||||||
const renderMessage = (message) => {
|
|
||||||
return bridge.renderFt8Message?.(message) ?? renderFtxMessage(message);
|
|
||||||
};
|
|
||||||
const retentionMs = () => bridge.getDecodeHistoryRetentionMs?.() ?? 864e5;
|
|
||||||
const prune = () => {
|
|
||||||
const cutoff = Date.now() - retentionMs();
|
|
||||||
history = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= cutoff);
|
|
||||||
};
|
|
||||||
const schedule = (job) => {
|
|
||||||
if (bridge.trxScheduleUiFrameJob) bridge.trxScheduleUiFrameJob(`${id}-history`, job);
|
|
||||||
else job();
|
|
||||||
};
|
|
||||||
const displayFrequency = (value) => {
|
|
||||||
const raw = finiteNumber(value);
|
|
||||||
if (raw === null) return null;
|
|
||||||
const base = finiteNumber(bridge.ft8BaseHz);
|
|
||||||
return base !== null && base > 0 && raw >= 0 && raw < 1e5 ? base + raw : raw;
|
|
||||||
};
|
|
||||||
const renderRow = (message) => {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
const raw = message.message ?? "";
|
|
||||||
row.dataset.message = raw.toUpperCase();
|
|
||||||
row.dataset.decoder = id;
|
|
||||||
const storedFrequency = finiteNumber(message.freq_hz);
|
|
||||||
row.dataset.storedFreqHz = storedFrequency === null ? "" : String(storedFrequency);
|
|
||||||
const snr = finiteNumber(message.snr_db);
|
|
||||||
const delta = finiteNumber(message.dt_s);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "--:--:--" : new Date(timestamp).toLocaleTimeString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit"
|
|
||||||
});
|
|
||||||
row.innerHTML = `<span class="ft8-time">${time}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${frequency?.toFixed(0) ?? "--"}</span><span class="ft8-msg">${renderMessage(raw)}</span>`;
|
|
||||||
return row;
|
|
||||||
};
|
|
||||||
const render = () => {
|
|
||||||
prune();
|
|
||||||
if (!messagesElement) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
let count = 0;
|
|
||||||
for (const message of history) {
|
|
||||||
if (count >= 200) break;
|
|
||||||
if (filterText && !(message.message ?? "").toUpperCase().includes(filterText)) continue;
|
|
||||||
fragment.appendChild(renderRow(message));
|
|
||||||
count += 1;
|
|
||||||
}
|
|
||||||
messagesElement.replaceChildren(fragment);
|
|
||||||
};
|
|
||||||
const normalize = (message) => {
|
|
||||||
const raw = message.message ?? "";
|
|
||||||
const locatorDetails = bridge.ft8ExtractLocatorDetails?.(raw) ?? extractFtxLocatorDetails(raw);
|
|
||||||
const grids = locatorDetails.length > 0 ? locatorDetails.map(({ grid }) => grid) : bridge.ft8ExtractAllGrids?.(raw) ?? extractFtxGrids(raw);
|
|
||||||
const station = bridge.ft8ExtractLikelyCallsign?.(raw) ?? extractFtxCallsign(raw);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
if (grids.length > 0) {
|
|
||||||
bridge.mapAddLocator?.(raw, grids, id, station, {
|
|
||||||
...message,
|
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
|
||||||
locator_details: locatorDetails
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
receiver: bridge.getDecodeRigMeta?.() ?? null,
|
|
||||||
ts_ms: message.ts_ms,
|
|
||||||
snr_db: message.snr_db,
|
|
||||||
dt_s: message.dt_s,
|
|
||||||
freq_hz: frequency ?? message.freq_hz,
|
|
||||||
message: message.message,
|
|
||||||
_tsMs: finiteNumber(message.ts_ms) ?? Date.now()
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const receiveBatch = (messages) => {
|
|
||||||
if (messages.length === 0) return;
|
|
||||||
if (status) status.textContent = "Receiving";
|
|
||||||
history = messages.map(normalize).reverse().concat(history);
|
|
||||||
prune();
|
|
||||||
bridge.setFt8FamilyBarDecoder?.(id);
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
schedule(render);
|
|
||||||
};
|
|
||||||
const reset = () => {
|
|
||||||
history = [];
|
|
||||||
bridge.updateFt8Bar?.();
|
|
||||||
render();
|
|
||||||
bridge.clearMapMarkersByType?.(id);
|
|
||||||
};
|
|
||||||
const barFrames = () => {
|
|
||||||
const recent = history.filter((message) => (finiteNumber(message._tsMs ?? message.ts_ms) ?? 0) >= Date.now() - 9e5).slice(0, 8);
|
|
||||||
let html = "";
|
|
||||||
for (const message of recent) {
|
|
||||||
const timestamp = finiteNumber(message._tsMs ?? message.ts_ms);
|
|
||||||
const time = timestamp === null ? "" : `<span class="aprs-bar-time">${bridge.fmtTime?.(timestamp) ?? ""}</span>`;
|
|
||||||
const snr = finiteNumber(message.snr_db);
|
|
||||||
const delta = finiteNumber(message.dt_s);
|
|
||||||
const frequency = displayFrequency(message.freq_hz);
|
|
||||||
const detail = [snr === null ? "-- dB" : `${snr.toFixed(1)} dB`, delta === null ? null : `dt ${delta.toFixed(2)}`, frequency === null ? null : `${frequency.toFixed(0)} Hz`].filter((part) => part !== null).join(" · ");
|
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${time}<span class="aprs-bar-call">${renderMessage(message.message ?? "")}</span>${detail ? ` · ${detail}` : ""}</div></div>`;
|
|
||||||
}
|
|
||||||
return { count: recent.length, newestTsMs: recent.reduce((latest, message) => Math.max(latest, finiteNumber(message._tsMs ?? message.ts_ms) ?? 0), 0), html };
|
|
||||||
};
|
|
||||||
bridge.trxPluginRuntime.registerDecoder({
|
|
||||||
id,
|
|
||||||
onMessage: (message) => {
|
|
||||||
receiveBatch([message]);
|
|
||||||
},
|
|
||||||
onBatch: receiveBatch,
|
|
||||||
restore: receiveBatch,
|
|
||||||
prune: () => {
|
|
||||||
prune();
|
|
||||||
render();
|
|
||||||
},
|
|
||||||
reset
|
|
||||||
});
|
|
||||||
bridge.registerFt8FamilyBarRenderer?.(id, barFrames);
|
|
||||||
const updatePeriod = () => {
|
|
||||||
if (period) period.textContent = `Next slot ${((periodMs - Date.now() % periodMs) / 1e3).toFixed(periodDigits)}s`;
|
|
||||||
};
|
|
||||||
updatePeriod();
|
|
||||||
window.setInterval(updatePeriod, 250);
|
|
||||||
filterInput?.addEventListener("input", () => {
|
|
||||||
filterText = filterInput.value.trim().toUpperCase();
|
|
||||||
render();
|
|
||||||
});
|
|
||||||
messagesElement?.addEventListener("click", (event) => {
|
|
||||||
if (!(event.target instanceof Element)) return;
|
|
||||||
const grid = event.target.closest(".ft8-locator[data-locator-grid]")?.dataset.locatorGrid;
|
|
||||||
if (grid) {
|
|
||||||
bridge.navigateToMapLocator?.(grid.toUpperCase(), id);
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const toggle = document.getElementById(`${id}-decode-toggle-btn`);
|
|
||||||
toggle?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await bridge.takeSchedulerControlForDecoderDisable?.(toggle);
|
|
||||||
await bridge.postPath?.(`/toggle_${id}_decode`);
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} toggle failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
document.getElementById(`settings-clear-${id}-history`)?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await bridge.trxUi.confirm({ title: `Clear ${label} history?`, message: `All stored ${label} decodes will be permanently removed.`, confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await bridge.postPath?.(`/clear_${id}_decode`);
|
|
||||||
reset();
|
|
||||||
} catch (error) {
|
|
||||||
console.error(`${label} history clear failed`, error);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/plugins/ft8.ts
|
// src/plugins/ft8.ts
|
||||||
installFtxCompatibilityHelpers();
|
installFtxCompatibilityHelpers();
|
||||||
initializeFt8FamilyBar();
|
initializeFt8FamilyBar();
|
||||||
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 });
|
initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 });
|
||||||
})();
|
|
||||||
|
|||||||
@@ -1,359 +1,265 @@
|
|||||||
"use strict";
|
import {
|
||||||
(() => {
|
aprsAgeText,
|
||||||
// src/plugins/aprs-shared.ts
|
aprsCategoryLabel,
|
||||||
function aprsPacketCategory(packet) {
|
aprsHexBytes,
|
||||||
const type = (packet.type ?? "").toLowerCase();
|
aprsPacketCategory,
|
||||||
const info = (packet.info ?? "").toLowerCase();
|
collapseAprsDuplicates,
|
||||||
if (packet.lat != null && packet.lon != null || type.includes("position")) return "position";
|
normalizeAprsPacket,
|
||||||
if (type.includes("message") || info.startsWith(":")) return "message";
|
renderAprsInfo,
|
||||||
if (type.includes("weather") || info.startsWith("_")) return "weather";
|
renderLocalAprsSymbol
|
||||||
if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry";
|
} from "./chunk-M2I6DH4X.js";
|
||||||
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(timestampMs) {
|
|
||||||
if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now";
|
|
||||||
const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3);
|
|
||||||
if (seconds < 5) return "just now";
|
|
||||||
if (seconds < 60) return `${String(seconds)}s ago`;
|
|
||||||
const minutes = Math.round(seconds / 60);
|
|
||||||
if (minutes < 60) return `${String(minutes)}m ago`;
|
|
||||||
return `${String(Math.round(minutes / 60))}h ago`;
|
|
||||||
}
|
|
||||||
function aprsPacketSignature(packet) {
|
|
||||||
return [
|
|
||||||
packet.srcCall ?? "",
|
|
||||||
packet.destCall ?? "",
|
|
||||||
packet.path ?? "",
|
|
||||||
packet.info ?? "",
|
|
||||||
packet.type ?? "",
|
|
||||||
packet.lat?.toFixed(4) ?? "",
|
|
||||||
packet.lon?.toFixed(4) ?? ""
|
|
||||||
].join("|");
|
|
||||||
}
|
|
||||||
function collapseAprsDuplicates(packets) {
|
|
||||||
const seen = /* @__PURE__ */ new Set();
|
|
||||||
return packets.filter((packet) => {
|
|
||||||
const signature = aprsPacketSignature(packet);
|
|
||||||
if (seen.has(signature)) return false;
|
|
||||||
seen.add(signature);
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function aprsHexBytes(bytes) {
|
|
||||||
if (!bytes?.length) return "--";
|
|
||||||
return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" ");
|
|
||||||
}
|
|
||||||
function renderAprsInfo(packet) {
|
|
||||||
if (packet.info_bytes?.length) {
|
|
||||||
return packet.info_bytes.map((byte) => renderAprsByte(byte)).join("");
|
|
||||||
}
|
|
||||||
return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join("");
|
|
||||||
}
|
|
||||||
function renderAprsByte(byte) {
|
|
||||||
return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `<span class="aprs-byte">0x${byte.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function renderAprsCharacter(character) {
|
|
||||||
const code = character.charCodeAt(0);
|
|
||||||
return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `<span class="aprs-byte">0x${code.toString(16).toUpperCase().padStart(2, "0")}</span>`;
|
|
||||||
}
|
|
||||||
function escapeAprsCharacter(character) {
|
|
||||||
if (character === "<") return "<";
|
|
||||||
if (character === ">") return ">";
|
|
||||||
if (character === "&") return "&";
|
|
||||||
if (character === '"') return """;
|
|
||||||
return character;
|
|
||||||
}
|
|
||||||
function renderLocalAprsSymbol(packet, escapeHtml) {
|
|
||||||
if (!packet.symbolTable || !packet.symbolCode) return "";
|
|
||||||
const symbol = escapeHtml(packet.symbolCode);
|
|
||||||
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
|
|
||||||
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
|
|
||||||
}
|
|
||||||
function normalizeAprsPacket(packet, receiver) {
|
|
||||||
return {
|
|
||||||
rig_id: packet.rig_id || null,
|
|
||||||
receiver,
|
|
||||||
srcCall: packet.src_call ?? "",
|
|
||||||
destCall: packet.dest_call ?? "",
|
|
||||||
path: packet.path ?? "",
|
|
||||||
info: packet.info ?? "",
|
|
||||||
info_bytes: packet.info_bytes ?? [],
|
|
||||||
type: packet.packet_type ?? "",
|
|
||||||
crcOk: packet.crc_ok ?? false,
|
|
||||||
ts_ms: packet.ts_ms ?? null,
|
|
||||||
lat: packet.lat ?? null,
|
|
||||||
lon: packet.lon ?? null,
|
|
||||||
symbolTable: packet.symbol_table ?? null,
|
|
||||||
symbolCode: packet.symbol_code ?? null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// src/plugins/hf-aprs.ts
|
// src/plugins/hf-aprs.ts
|
||||||
var hfAprsWindow = window;
|
var hfAprsWindow = window;
|
||||||
var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
var hfAprsStatus = document.getElementById("hf-aprs-status");
|
||||||
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
var hfAprsPacketsEl = document.getElementById("hf-aprs-packets");
|
||||||
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
var hfAprsFilterInput = document.getElementById("hf-aprs-filter");
|
||||||
var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn");
|
||||||
var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn");
|
||||||
var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn");
|
||||||
var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count");
|
||||||
var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count");
|
||||||
var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen");
|
||||||
var hfAprsFilterText = "";
|
var hfAprsFilterText = "";
|
||||||
var hfAprsPacketHistory = [];
|
var hfAprsPacketHistory = [];
|
||||||
var hfAprsOnlyPos = false;
|
var hfAprsOnlyPos = false;
|
||||||
var hfAprsHideCrc = false;
|
var hfAprsHideCrc = false;
|
||||||
var hfAprsCollapseDup = false;
|
var hfAprsCollapseDup = false;
|
||||||
var hfAprsTypeFilter = "all";
|
var hfAprsTypeFilter = "all";
|
||||||
function currentHfAprsHistoryRetentionMs() {
|
function currentHfAprsHistoryRetentionMs() {
|
||||||
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
}
|
}
|
||||||
function pruneHfAprsPacketHistory() {
|
function pruneHfAprsPacketHistory() {
|
||||||
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs();
|
||||||
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs);
|
||||||
}
|
}
|
||||||
function scheduleHfAprsHistoryRender() {
|
function scheduleHfAprsHistoryRender() {
|
||||||
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
|
if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") {
|
||||||
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => {
|
hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => {
|
||||||
renderHfAprsHistory();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderHfAprsHistory();
|
|
||||||
}
|
|
||||||
function hfAprsDistanceText(pkt) {
|
|
||||||
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return "";
|
|
||||||
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.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 hfAprsFilterMatch(pkt) {
|
|
||||||
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
|
||||||
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
|
||||||
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(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) : "",
|
|
||||||
aprsPacketCategory(pkt)
|
|
||||||
].filter(Boolean).join(" ").toUpperCase();
|
|
||||||
return haystack.includes(hfAprsFilterText);
|
|
||||||
}
|
|
||||||
function hfAprsVisiblePackets() {
|
|
||||||
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
|
||||||
return packets.filter(hfAprsFilterMatch);
|
|
||||||
}
|
|
||||||
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
|
||||||
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} ${aprsAgeText(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 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 = 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">${escapeHfAprsHtml(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>';
|
|
||||||
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
|
||||||
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">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
|
||||||
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
|
||||||
el.addEventListener("click", (evt) => {
|
|
||||||
evt.preventDefault();
|
|
||||||
const raw = el.dataset.aprsMap ?? "";
|
|
||||||
const [lat, lon] = raw.split(",").map(Number);
|
|
||||||
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
|
||||||
hfAprsWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const copyBtn = row.querySelector("[data-aprs-copy]");
|
|
||||||
if (copyBtn) {
|
|
||||||
copyBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
const raw = copyBtn.dataset.aprsCopy ?? "";
|
|
||||||
try {
|
|
||||||
const clipboard = Reflect.get(navigator, "clipboard");
|
|
||||||
if (clipboard) {
|
|
||||||
await clipboard.writeText(raw);
|
|
||||||
hfAprsWindow.showHint?.("Coordinates copied", 1200);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
hfAprsWindow.showHint?.("Copy failed", 1500);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function renderHfAprsHistory() {
|
|
||||||
pruneHfAprsPacketHistory();
|
|
||||||
if (!hfAprsPacketsEl) {
|
|
||||||
updateHfAprsSummary();
|
|
||||||
updateHfAprsChipState();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const visible = hfAprsVisiblePackets();
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (const [index, packet] of visible.entries()) {
|
|
||||||
fragment.appendChild(renderHfAprsRow(packet, index === 0));
|
|
||||||
}
|
|
||||||
hfAprsPacketsEl.replaceChildren(fragment);
|
|
||||||
updateHfAprsSummary();
|
|
||||||
updateHfAprsChipState();
|
|
||||||
}
|
|
||||||
function resetHfAprsHistoryView() {
|
|
||||||
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
|
||||||
hfAprsPacketHistory = [];
|
|
||||||
renderHfAprsHistory();
|
|
||||||
}
|
|
||||||
function pruneHfAprsHistoryView() {
|
|
||||||
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 normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
|
|
||||||
}
|
|
||||||
function onServerHfAprsBatch(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();
|
|
||||||
}
|
|
||||||
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
|
||||||
hfAprsDecodeToggleBtn?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
|
||||||
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("HF APRS toggle failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
|
|
||||||
resetHfAprsHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("HF APRS history clear failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
if (hfAprsOnlyPosBtn) {
|
|
||||||
hfAprsOnlyPosBtn.addEventListener("click", () => {
|
|
||||||
hfAprsOnlyPos = !hfAprsOnlyPos;
|
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
});
|
});
|
||||||
}
|
return;
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onServerHfAprs(pkt) {
|
|
||||||
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
|
||||||
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
|
||||||
}
|
}
|
||||||
renderHfAprsHistory();
|
renderHfAprsHistory();
|
||||||
window.trxPluginRuntime.registerDecoder({
|
}
|
||||||
id: "hf_aprs",
|
function hfAprsDistanceText(pkt) {
|
||||||
onMessage: onServerHfAprs,
|
if (hfAprsWindow.serverLat == null || hfAprsWindow.serverLon == null || pkt.lat == null || pkt.lon == null || !hfAprsWindow.haversineKm) return "";
|
||||||
onBatch: onServerHfAprsBatch,
|
const distKm = hfAprsWindow.haversineKm(hfAprsWindow.serverLat, hfAprsWindow.serverLon, pkt.lat, pkt.lon);
|
||||||
restore: onServerHfAprsBatch,
|
if (!Number.isFinite(distKm)) return "";
|
||||||
reset: resetHfAprsHistoryView,
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
prune: pruneHfAprsHistoryView
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
|
}
|
||||||
|
function hfAprsFilterMatch(pkt) {
|
||||||
|
if (hfAprsOnlyPos && (pkt.lat == null || pkt.lon == null)) return false;
|
||||||
|
if (hfAprsHideCrc && !pkt.crcOk) return false;
|
||||||
|
if (hfAprsTypeFilter !== "all" && aprsPacketCategory(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) : "",
|
||||||
|
aprsPacketCategory(pkt)
|
||||||
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
|
return haystack.includes(hfAprsFilterText);
|
||||||
|
}
|
||||||
|
function hfAprsVisiblePackets() {
|
||||||
|
const packets = hfAprsCollapseDup ? collapseHfAprsDuplicates(hfAprsPacketHistory) : hfAprsPacketHistory;
|
||||||
|
return packets.filter(hfAprsFilterMatch);
|
||||||
|
}
|
||||||
|
var collapseHfAprsDuplicates = collapseAprsDuplicates;
|
||||||
|
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} ${aprsAgeText(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 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 = 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">${escapeHfAprsHtml(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>';
|
||||||
|
const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml);
|
||||||
|
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">${escapeHfAprsHtml(pkt.srcCall ?? "")}</span><span>>${escapeHfAprsHtml(pkt.destCall || "")}</span><span class="${categoryClass}">${escapeHfAprsHtml(categoryLabel)}</span>` + pathBadge + crcBadge + `</div><div class="aprs-row-meta"><span class="aprs-meta-text">${escapeHfAprsHtml(age)}</span>` + (distance ? `<span class="aprs-meta-text">${escapeHfAprsHtml(distance)}</span>` : "") + `<span class="aprs-meta-text">${escapeHfAprsHtml(pkt.type || "--")}</span></div><div class="aprs-row-detail"><span title="${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.srcCall || "--")}</span><span class="aprs-detail-label">Destination</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.destCall || "--")}</span><span class="aprs-detail-label">Type</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.type || "--")}</span><span class="aprs-detail-label">Path</span><span class="aprs-detail-value">${escapeHfAprsHtml(pkt.path || "--")}</span><span class="aprs-detail-label">Age</span><span class="aprs-detail-value">${escapeHfAprsHtml(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">${escapeHfAprsHtml(pkt.info || "--")}</span><span class="aprs-detail-label">Info Bytes</span><span class="aprs-detail-value">${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}</span></div></details>`;
|
||||||
|
row.querySelectorAll("[data-aprs-map]").forEach((el) => {
|
||||||
|
el.addEventListener("click", (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const raw = el.dataset.aprsMap ?? "";
|
||||||
|
const [lat, lon] = raw.split(",").map(Number);
|
||||||
|
if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
hfAprsWindow.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const copyBtn = row.querySelector("[data-aprs-copy]");
|
||||||
|
if (copyBtn) {
|
||||||
|
copyBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
const raw = copyBtn.dataset.aprsCopy ?? "";
|
||||||
|
try {
|
||||||
|
const clipboard = Reflect.get(navigator, "clipboard");
|
||||||
|
if (clipboard) {
|
||||||
|
await clipboard.writeText(raw);
|
||||||
|
hfAprsWindow.showHint?.("Coordinates copied", 1200);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
hfAprsWindow.showHint?.("Copy failed", 1500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderHfAprsHistory() {
|
||||||
|
pruneHfAprsPacketHistory();
|
||||||
|
if (!hfAprsPacketsEl) {
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const visible = hfAprsVisiblePackets();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const [index, packet] of visible.entries()) {
|
||||||
|
fragment.appendChild(renderHfAprsRow(packet, index === 0));
|
||||||
|
}
|
||||||
|
hfAprsPacketsEl.replaceChildren(fragment);
|
||||||
|
updateHfAprsSummary();
|
||||||
|
updateHfAprsChipState();
|
||||||
|
}
|
||||||
|
function resetHfAprsHistoryView() {
|
||||||
|
if (hfAprsPacketsEl) hfAprsPacketsEl.innerHTML = "";
|
||||||
|
hfAprsPacketHistory = [];
|
||||||
|
renderHfAprsHistory();
|
||||||
|
}
|
||||||
|
function pruneHfAprsHistoryView() {
|
||||||
|
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 normalizeAprsPacket(pkt, hfAprsWindow.getDecodeRigMeta?.() ?? null);
|
||||||
|
}
|
||||||
|
function onServerHfAprsBatch(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();
|
||||||
|
}
|
||||||
|
var hfAprsDecodeToggleBtn = document.getElementById("hf-aprs-decode-toggle-btn");
|
||||||
|
hfAprsDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await hfAprsWindow.takeSchedulerControlForDecoderDisable?.(hfAprsDecodeToggleBtn);
|
||||||
|
await hfAprsWindow.postPath?.("/toggle_hf_aprs_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("HF APRS toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-hf-aprs-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await hfAprsWindow.trxUi.confirm({ title: "Clear HF APRS history?", message: "All stored HF APRS packets will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await hfAprsWindow.postPath?.("/clear_hf_aprs_decode");
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function onServerHfAprs(pkt) {
|
||||||
|
if (hfAprsStatus) hfAprsStatus.textContent = "Receiving";
|
||||||
|
addHfAprsPacket(normalizeServerHfAprsPacket(pkt));
|
||||||
|
}
|
||||||
|
renderHfAprsHistory();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "hf_aprs",
|
||||||
|
onMessage: onServerHfAprs,
|
||||||
|
onBatch: onServerHfAprsBatch,
|
||||||
|
restore: onServerHfAprsBatch,
|
||||||
|
reset: resetHfAprsHistoryView,
|
||||||
|
prune: pruneHfAprsHistoryView
|
||||||
|
});
|
||||||
|
|||||||
+91
-94
@@ -1,98 +1,95 @@
|
|||||||
"use strict";
|
// src/leaflet-ais-tracksymbol.ts
|
||||||
(() => {
|
(function() {
|
||||||
// src/leaflet-ais-tracksymbol.ts
|
const leaflet = globalThis.L;
|
||||||
(function() {
|
if (!leaflet) return;
|
||||||
const leaflet = globalThis.L;
|
function clamp(value, min, max) {
|
||||||
if (!leaflet) return;
|
return Math.max(min, Math.min(max, value));
|
||||||
function clamp(value, min, max) {
|
}
|
||||||
return Math.max(min, Math.min(max, value));
|
function finiteAngle(value) {
|
||||||
}
|
if (value === null || !Number.isFinite(value)) return null;
|
||||||
function finiteAngle(value) {
|
const normalized = (value % 360 + 360) % 360;
|
||||||
if (value === null || !Number.isFinite(value)) return null;
|
return normalized;
|
||||||
const normalized = (value % 360 + 360) % 360;
|
}
|
||||||
return normalized;
|
function svgColor(value, fallback) {
|
||||||
}
|
const text = value || fallback || "";
|
||||||
function svgColor(value, fallback) {
|
return text.replace(/"/g, """);
|
||||||
const text = value || fallback || "";
|
}
|
||||||
return text.replace(/"/g, """);
|
function buildSymbolHtml(options, zoom) {
|
||||||
}
|
const heading = finiteAngle(options.heading);
|
||||||
function buildSymbolHtml(options, zoom) {
|
const course = finiteAngle(options.course);
|
||||||
const heading = finiteAngle(options.heading);
|
const angle = heading != null ? heading : course;
|
||||||
const course = finiteAngle(options.course);
|
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||||||
const angle = heading != null ? heading : course;
|
const sizeBase = Number.isFinite(options.size) ? options.size : 22;
|
||||||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
const sizeBase = Number.isFinite(options.size) ? options.size : 22;
|
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>`;
|
||||||
|
}
|
||||||
|
leaflet.TrxAisTrackSymbol = leaflet.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 = leaflet.Util.extend({}, this.options, options || {});
|
||||||
|
merged.icon = leaflet.divIcon({
|
||||||
|
className: "trx-ais-track-symbol-icon",
|
||||||
|
html: "",
|
||||||
|
iconSize: [merged.size, merged.size],
|
||||||
|
iconAnchor: [merged.size / 2, merged.size / 2]
|
||||||
|
});
|
||||||
|
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
|
||||||
|
},
|
||||||
|
onAdd: function(map) {
|
||||||
|
leaflet.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;
|
||||||
|
}
|
||||||
|
leaflet.Marker.prototype.onRemove.call(this, map);
|
||||||
|
},
|
||||||
|
setAisState: function(next) {
|
||||||
|
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) ? this.options.size : 22;
|
||||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
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;
|
this._icon.style.width = `${size}px`;
|
||||||
const color = svgColor(options.color, "#ff7559");
|
this._icon.style.height = `${size}px`;
|
||||||
const outline = svgColor(options.outline, "#6b2118");
|
this._icon.style.marginLeft = `${-size / 2}px`;
|
||||||
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" />`;
|
this._icon.style.marginTop = `${-size / 2}px`;
|
||||||
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>`;
|
|
||||||
}
|
}
|
||||||
leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
|
});
|
||||||
options: {
|
leaflet.trxAisTrackSymbol = function(latlng, options) {
|
||||||
heading: null,
|
const Constructor = leaflet.TrxAisTrackSymbol;
|
||||||
course: null,
|
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
||||||
speed: null,
|
return new Constructor(latlng, options);
|
||||||
color: "#ff7559",
|
};
|
||||||
outline: "#6b2118",
|
|
||||||
size: 22,
|
|
||||||
interactive: true,
|
|
||||||
keyboard: true,
|
|
||||||
riseOnHover: true
|
|
||||||
},
|
|
||||||
initialize: function(latlng, options) {
|
|
||||||
const merged = leaflet.Util.extend({}, this.options, options || {});
|
|
||||||
merged.icon = leaflet.divIcon({
|
|
||||||
className: "trx-ais-track-symbol-icon",
|
|
||||||
html: "",
|
|
||||||
iconSize: [merged.size, merged.size],
|
|
||||||
iconAnchor: [merged.size / 2, merged.size / 2]
|
|
||||||
});
|
|
||||||
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
|
|
||||||
},
|
|
||||||
onAdd: function(map) {
|
|
||||||
leaflet.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;
|
|
||||||
}
|
|
||||||
leaflet.Marker.prototype.onRemove.call(this, map);
|
|
||||||
},
|
|
||||||
setAisState: function(next) {
|
|
||||||
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) ? 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`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
leaflet.trxAisTrackSymbol = function(latlng, options) {
|
|
||||||
const Constructor = leaflet.TrxAisTrackSymbol;
|
|
||||||
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
|
||||||
return new Constructor(latlng, options);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
+3010
-3013
File diff suppressed because it is too large
Load Diff
+251
-254
@@ -1,266 +1,263 @@
|
|||||||
"use strict";
|
// src/plugins/sat-scheduler.ts
|
||||||
(() => {
|
var satSchedulerWindow = window;
|
||||||
// src/plugins/sat-scheduler.ts
|
(function() {
|
||||||
var satSchedulerWindow = window;
|
"use strict";
|
||||||
(function() {
|
const dom = {
|
||||||
"use strict";
|
enabled: document.getElementById("scheduler-sat-enabled"),
|
||||||
const dom = {
|
pretune: document.getElementById("scheduler-sat-pretune"),
|
||||||
enabled: document.getElementById("scheduler-sat-enabled"),
|
body: document.getElementById("scheduler-sat-body"),
|
||||||
pretune: document.getElementById("scheduler-sat-pretune"),
|
tbody: document.getElementById("scheduler-sat-tbody"),
|
||||||
body: document.getElementById("scheduler-sat-body"),
|
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
||||||
tbody: document.getElementById("scheduler-sat-tbody"),
|
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
||||||
addBtn: document.getElementById("scheduler-sat-add-btn"),
|
formWrap: document.getElementById("sch-sat-form-wrap"),
|
||||||
passStatus: document.getElementById("scheduler-sat-pass-status"),
|
formTitle: document.getElementById("sch-sat-form-title"),
|
||||||
formWrap: document.getElementById("sch-sat-form-wrap"),
|
form: document.getElementById("sch-sat-form"),
|
||||||
formTitle: document.getElementById("sch-sat-form-title"),
|
formCancel: document.getElementById("sch-sat-form-cancel"),
|
||||||
form: document.getElementById("sch-sat-form"),
|
preset: document.getElementById("scheduler-sat-preset"),
|
||||||
formCancel: document.getElementById("sch-sat-form-cancel"),
|
name: document.getElementById("scheduler-sat-name"),
|
||||||
preset: document.getElementById("scheduler-sat-preset"),
|
norad: document.getElementById("scheduler-sat-norad"),
|
||||||
name: document.getElementById("scheduler-sat-name"),
|
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
||||||
norad: document.getElementById("scheduler-sat-norad"),
|
minEl: document.getElementById("scheduler-sat-min-el"),
|
||||||
bookmark: document.getElementById("scheduler-sat-bookmark"),
|
priority: document.getElementById("scheduler-sat-priority"),
|
||||||
minEl: document.getElementById("scheduler-sat-min-el"),
|
centerHz: document.getElementById("scheduler-sat-center-hz")
|
||||||
priority: document.getElementById("scheduler-sat-priority"),
|
};
|
||||||
centerHz: document.getElementById("scheduler-sat-center-hz")
|
let editIdx = null;
|
||||||
|
let eventsWired = false;
|
||||||
|
function getBridge() {
|
||||||
|
return satSchedulerWindow.trx?.modules?.scheduler ?? null;
|
||||||
|
}
|
||||||
|
function getConfig() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getConfig() ?? null;
|
||||||
|
}
|
||||||
|
function getStatus() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getStatus() ?? null;
|
||||||
|
}
|
||||||
|
function getBookmarks() {
|
||||||
|
const b = getBridge();
|
||||||
|
return b?.getBookmarks() ?? [];
|
||||||
|
}
|
||||||
|
function markDirty() {
|
||||||
|
getBridge()?.markDirty();
|
||||||
|
}
|
||||||
|
function bmName(id) {
|
||||||
|
const bm = getBookmarks().find(function(b) {
|
||||||
|
return b.id === id;
|
||||||
|
});
|
||||||
|
return bm ? bm.name : id;
|
||||||
|
}
|
||||||
|
function escHtml(s) {
|
||||||
|
return 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 `${String(hz)} Hz`;
|
||||||
|
}
|
||||||
|
function getSatelliteEntries() {
|
||||||
|
const config = getConfig();
|
||||||
|
return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : [];
|
||||||
|
}
|
||||||
|
function ensureSatelliteConfig() {
|
||||||
|
const config = getConfig();
|
||||||
|
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
||||||
|
return config.satellites;
|
||||||
|
}
|
||||||
|
function collectSatelliteConfig() {
|
||||||
|
const enabled = dom.enabled ? dom.enabled.checked : false;
|
||||||
|
const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
||||||
|
return {
|
||||||
|
enabled,
|
||||||
|
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
||||||
|
entries: getSatelliteEntries()
|
||||||
};
|
};
|
||||||
let editIdx = null;
|
}
|
||||||
let eventsWired = false;
|
function renderSection() {
|
||||||
function getBridge() {
|
const config = getConfig();
|
||||||
return satSchedulerWindow.trx?.modules?.scheduler ?? null;
|
const satCfg = config?.satellites;
|
||||||
}
|
const enabled = satCfg?.enabled ?? false;
|
||||||
function getConfig() {
|
if (dom.enabled) dom.enabled.checked = enabled;
|
||||||
const b = getBridge();
|
if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
|
||||||
return b?.getConfig() ?? null;
|
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
||||||
}
|
renderEntries();
|
||||||
function getStatus() {
|
renderPassStatus();
|
||||||
const b = getBridge();
|
}
|
||||||
return b?.getStatus() ?? null;
|
function renderEntries() {
|
||||||
}
|
if (!dom.tbody) return;
|
||||||
function getBookmarks() {
|
const entries = getSatelliteEntries();
|
||||||
const b = getBridge();
|
const frag = document.createDocumentFragment();
|
||||||
return b?.getBookmarks() ?? [];
|
entries.forEach(function(entry, idx) {
|
||||||
}
|
const tr = document.createElement("tr");
|
||||||
function markDirty() {
|
const tdSat = document.createElement("td");
|
||||||
getBridge()?.markDirty();
|
tdSat.textContent = entry.satellite || "";
|
||||||
}
|
tr.appendChild(tdSat);
|
||||||
function bmName(id) {
|
const tdNorad = document.createElement("td");
|
||||||
const bm = getBookmarks().find(function(b) {
|
tdNorad.textContent = String(entry.norad_id || "");
|
||||||
return b.id === id;
|
tr.appendChild(tdNorad);
|
||||||
|
const tdBm = document.createElement("td");
|
||||||
|
tdBm.textContent = bmName(entry.bookmark_id);
|
||||||
|
tr.appendChild(tdBm);
|
||||||
|
const tdEl = document.createElement("td");
|
||||||
|
tdEl.textContent = `${String(entry.min_elevation_deg)}°`;
|
||||||
|
tr.appendChild(tdEl);
|
||||||
|
const tdPrio = document.createElement("td");
|
||||||
|
tdPrio.textContent = String(entry.priority || 0);
|
||||||
|
tr.appendChild(tdPrio);
|
||||||
|
const tdActions = document.createElement("td");
|
||||||
|
const editBtn = document.createElement("button");
|
||||||
|
editBtn.className = "sch-write";
|
||||||
|
editBtn.type = "button";
|
||||||
|
editBtn.textContent = "Edit";
|
||||||
|
editBtn.addEventListener("click", function() {
|
||||||
|
openForm(entry, idx);
|
||||||
});
|
});
|
||||||
return bm ? bm.name : id;
|
tdActions.appendChild(editBtn);
|
||||||
}
|
const removeBtn = document.createElement("button");
|
||||||
function escHtml(s) {
|
removeBtn.className = "sch-write";
|
||||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
removeBtn.type = "button";
|
||||||
}
|
removeBtn.textContent = "Remove";
|
||||||
function formatFreq(hz) {
|
removeBtn.addEventListener("click", function() {
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz";
|
removeEntry(idx);
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz";
|
|
||||||
return `${String(hz)} Hz`;
|
|
||||||
}
|
|
||||||
function getSatelliteEntries() {
|
|
||||||
const config = getConfig();
|
|
||||||
return config && config.satellites && Array.isArray(config.satellites.entries) ? config.satellites.entries : [];
|
|
||||||
}
|
|
||||||
function ensureSatelliteConfig() {
|
|
||||||
const config = getConfig();
|
|
||||||
if (!config) return { enabled: false, pretune_secs: 60, entries: [] };
|
|
||||||
if (!config.satellites) config.satellites = { enabled: false, pretune_secs: 60, entries: [] };
|
|
||||||
return config.satellites;
|
|
||||||
}
|
|
||||||
function collectSatelliteConfig() {
|
|
||||||
const enabled = dom.enabled ? dom.enabled.checked : false;
|
|
||||||
const pretune = dom.pretune ? parseInt(dom.pretune.value, 10) : 60;
|
|
||||||
return {
|
|
||||||
enabled,
|
|
||||||
pretune_secs: isNaN(pretune) || pretune < 0 ? 60 : pretune,
|
|
||||||
entries: getSatelliteEntries()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function renderSection() {
|
|
||||||
const config = getConfig();
|
|
||||||
const satCfg = config?.satellites;
|
|
||||||
const enabled = satCfg?.enabled ?? false;
|
|
||||||
if (dom.enabled) dom.enabled.checked = enabled;
|
|
||||||
if (dom.pretune) dom.pretune.value = String(satCfg?.pretune_secs ?? 60);
|
|
||||||
if (dom.body) dom.body.style.display = enabled ? "" : "none";
|
|
||||||
renderEntries();
|
|
||||||
renderPassStatus();
|
|
||||||
}
|
|
||||||
function renderEntries() {
|
|
||||||
if (!dom.tbody) return;
|
|
||||||
const entries = getSatelliteEntries();
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
entries.forEach(function(entry, idx) {
|
|
||||||
const tr = document.createElement("tr");
|
|
||||||
const tdSat = document.createElement("td");
|
|
||||||
tdSat.textContent = entry.satellite || "";
|
|
||||||
tr.appendChild(tdSat);
|
|
||||||
const tdNorad = document.createElement("td");
|
|
||||||
tdNorad.textContent = String(entry.norad_id || "");
|
|
||||||
tr.appendChild(tdNorad);
|
|
||||||
const tdBm = document.createElement("td");
|
|
||||||
tdBm.textContent = bmName(entry.bookmark_id);
|
|
||||||
tr.appendChild(tdBm);
|
|
||||||
const tdEl = document.createElement("td");
|
|
||||||
tdEl.textContent = `${String(entry.min_elevation_deg)}°`;
|
|
||||||
tr.appendChild(tdEl);
|
|
||||||
const tdPrio = document.createElement("td");
|
|
||||||
tdPrio.textContent = String(entry.priority || 0);
|
|
||||||
tr.appendChild(tdPrio);
|
|
||||||
const tdActions = document.createElement("td");
|
|
||||||
const editBtn = document.createElement("button");
|
|
||||||
editBtn.className = "sch-write";
|
|
||||||
editBtn.type = "button";
|
|
||||||
editBtn.textContent = "Edit";
|
|
||||||
editBtn.addEventListener("click", function() {
|
|
||||||
openForm(entry, idx);
|
|
||||||
});
|
|
||||||
tdActions.appendChild(editBtn);
|
|
||||||
const 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);
|
tdActions.appendChild(removeBtn);
|
||||||
|
tr.appendChild(tdActions);
|
||||||
|
frag.appendChild(tr);
|
||||||
|
});
|
||||||
|
dom.tbody.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
function renderPassStatus() {
|
||||||
|
if (!dom.passStatus) return;
|
||||||
|
const entries = getSatelliteEntries();
|
||||||
|
if (entries.length === 0) {
|
||||||
|
dom.passStatus.innerHTML = "";
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function renderPassStatus() {
|
const status = getStatus();
|
||||||
if (!dom.passStatus) return;
|
if (status && status.active_satellite) {
|
||||||
const entries = getSatelliteEntries();
|
dom.passStatus.innerHTML = '<span class="sch-sat-active-badge">PASS ACTIVE: ' + escHtml(status.active_satellite) + "</span>";
|
||||||
if (entries.length === 0) {
|
} else {
|
||||||
dom.passStatus.innerHTML = "";
|
dom.passStatus.innerHTML = '<span style="color:var(--text-muted);font-size:0.8rem;">No satellite pass active. Predictions available in the SAT tab.</span>';
|
||||||
return;
|
|
||||||
}
|
|
||||||
const 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) {
|
}
|
||||||
const bookmarkSelect = dom.bookmark;
|
function renderBookmarkSelect(selectedId) {
|
||||||
if (!bookmarkSelect) return;
|
const bookmarkSelect = dom.bookmark;
|
||||||
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
|
if (!bookmarkSelect) return;
|
||||||
getBookmarks().forEach(function(bm) {
|
bookmarkSelect.innerHTML = '<option value="">— none —</option>';
|
||||||
const opt = document.createElement("option");
|
getBookmarks().forEach(function(bm) {
|
||||||
opt.value = bm.id;
|
const opt = document.createElement("option");
|
||||||
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
opt.value = bm.id;
|
||||||
if (bm.id === selectedId) opt.selected = true;
|
opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")";
|
||||||
bookmarkSelect.appendChild(opt);
|
if (bm.id === selectedId) opt.selected = true;
|
||||||
});
|
bookmarkSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function removeEntry(idx) {
|
||||||
|
const 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 ? String(entry.norad_id || "") : "";
|
||||||
|
if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
|
||||||
|
if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
|
||||||
|
if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(entry.center_hz) : "";
|
||||||
|
renderBookmarkSelect(entry ? entry.bookmark_id : null);
|
||||||
|
if (dom.formWrap) {
|
||||||
|
dom.formWrap.style.display = "flex";
|
||||||
|
if (dom.name) dom.name.focus();
|
||||||
}
|
}
|
||||||
function removeEntry(idx) {
|
}
|
||||||
const sat = ensureSatelliteConfig();
|
function closeForm() {
|
||||||
sat.entries.splice(idx, 1);
|
if (dom.formWrap) dom.formWrap.style.display = "none";
|
||||||
renderEntries();
|
editIdx = null;
|
||||||
markDirty();
|
}
|
||||||
|
function onFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const satellite = dom.name ? dom.name.value.trim() : "";
|
||||||
|
const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
||||||
|
const bmId = dom.bookmark ? dom.bookmark.value : "";
|
||||||
|
if (!satellite) {
|
||||||
|
satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" });
|
||||||
|
dom.name?.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function openForm(entry, idx) {
|
if (isNaN(noradId) || noradId <= 0) {
|
||||||
editIdx = idx != null ? idx : null;
|
satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" });
|
||||||
if (dom.formTitle) dom.formTitle.textContent = entry ? "Edit Satellite" : "Add Satellite";
|
dom.norad?.focus();
|
||||||
if (dom.preset) dom.preset.value = "";
|
return;
|
||||||
if (dom.name) dom.name.value = entry ? entry.satellite || "" : "";
|
|
||||||
if (dom.norad) dom.norad.value = entry ? String(entry.norad_id || "") : "";
|
|
||||||
if (dom.minEl) dom.minEl.value = String(entry?.min_elevation_deg ?? 5);
|
|
||||||
if (dom.priority) dom.priority.value = String(entry?.priority ?? 0);
|
|
||||||
if (dom.centerHz) dom.centerHz.value = entry?.center_hz ? String(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 (!bmId) {
|
||||||
if (dom.formWrap) dom.formWrap.style.display = "none";
|
satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" });
|
||||||
editIdx = null;
|
dom.bookmark?.focus();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function onFormSubmit(e) {
|
const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
||||||
e.preventDefault();
|
const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
||||||
const satellite = dom.name ? dom.name.value.trim() : "";
|
const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
||||||
const noradId = dom.norad ? parseInt(dom.norad.value, 10) : NaN;
|
const sat = ensureSatelliteConfig();
|
||||||
const bmId = dom.bookmark ? dom.bookmark.value : "";
|
const existing = editIdx !== null ? sat.entries[editIdx] : void 0;
|
||||||
if (!satellite) {
|
const entryData = {
|
||||||
satSchedulerWindow.trxUi?.notify("Enter a satellite name.", { kind: "error" });
|
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
|
||||||
dom.name?.focus();
|
satellite,
|
||||||
return;
|
norad_id: noradId,
|
||||||
}
|
bookmark_id: bmId,
|
||||||
if (isNaN(noradId) || noradId <= 0) {
|
min_elevation_deg: isNaN(minEl) ? 5 : minEl,
|
||||||
satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" });
|
priority: isNaN(prio) ? 0 : prio,
|
||||||
dom.norad?.focus();
|
center_hz: !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null,
|
||||||
return;
|
bookmark_ids: []
|
||||||
}
|
|
||||||
if (!bmId) {
|
|
||||||
satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" });
|
|
||||||
dom.bookmark?.focus();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const minEl = dom.minEl ? parseFloat(dom.minEl.value) : 5;
|
|
||||||
const prio = dom.priority ? parseInt(dom.priority.value, 10) : 0;
|
|
||||||
const centerHzRaw = dom.centerHz ? parseInt(dom.centerHz.value, 10) : NaN;
|
|
||||||
const sat = ensureSatelliteConfig();
|
|
||||||
const existing = editIdx !== null ? sat.entries[editIdx] : void 0;
|
|
||||||
const entryData = {
|
|
||||||
id: existing?.id ?? `sat_${Date.now().toString(36)}`,
|
|
||||||
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) {
|
|
||||||
sat.entries[editIdx] = entryData;
|
|
||||||
} else {
|
|
||||||
sat.entries.push(entryData);
|
|
||||||
}
|
|
||||||
closeForm();
|
|
||||||
renderEntries();
|
|
||||||
markDirty();
|
|
||||||
}
|
|
||||||
function onPresetChange() {
|
|
||||||
if (!dom.preset || !dom.preset.value) return;
|
|
||||||
const parts = dom.preset.value.split("|");
|
|
||||||
if (dom.name) dom.name.value = parts[0] || "";
|
|
||||||
if (dom.norad) dom.norad.value = parts[1] || "";
|
|
||||||
}
|
|
||||||
function wireEvents() {
|
|
||||||
if (eventsWired) return;
|
|
||||||
eventsWired = true;
|
|
||||||
if (dom.enabled) {
|
|
||||||
const enabledInput = dom.enabled;
|
|
||||||
dom.enabled.addEventListener("change", function() {
|
|
||||||
if (dom.body) dom.body.style.display = enabledInput.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);
|
|
||||||
}
|
|
||||||
satSchedulerWindow.satScheduler = {
|
|
||||||
wireEvents,
|
|
||||||
renderSection,
|
|
||||||
renderPassStatus,
|
|
||||||
collectSatelliteConfig
|
|
||||||
};
|
};
|
||||||
if (getBridge()) {
|
if (editIdx !== null) {
|
||||||
wireEvents();
|
sat.entries[editIdx] = entryData;
|
||||||
renderSection();
|
} else {
|
||||||
|
sat.entries.push(entryData);
|
||||||
}
|
}
|
||||||
})();
|
closeForm();
|
||||||
|
renderEntries();
|
||||||
|
markDirty();
|
||||||
|
}
|
||||||
|
function onPresetChange() {
|
||||||
|
if (!dom.preset || !dom.preset.value) return;
|
||||||
|
const parts = dom.preset.value.split("|");
|
||||||
|
if (dom.name) dom.name.value = parts[0] || "";
|
||||||
|
if (dom.norad) dom.norad.value = parts[1] || "";
|
||||||
|
}
|
||||||
|
function wireEvents() {
|
||||||
|
if (eventsWired) return;
|
||||||
|
eventsWired = true;
|
||||||
|
if (dom.enabled) {
|
||||||
|
const enabledInput = dom.enabled;
|
||||||
|
dom.enabled.addEventListener("change", function() {
|
||||||
|
if (dom.body) dom.body.style.display = enabledInput.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);
|
||||||
|
}
|
||||||
|
satSchedulerWindow.satScheduler = {
|
||||||
|
wireEvents,
|
||||||
|
renderSection,
|
||||||
|
renderPassStatus,
|
||||||
|
collectSatelliteConfig
|
||||||
|
};
|
||||||
|
if (getBridge()) {
|
||||||
|
wireEvents();
|
||||||
|
renderSection();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,482 +1,479 @@
|
|||||||
"use strict";
|
// src/plugins/sat.ts
|
||||||
(() => {
|
var satWindow = window;
|
||||||
// src/plugins/sat.ts
|
var satDom = {
|
||||||
var satWindow = window;
|
status: document.getElementById("sat-status"),
|
||||||
var satDom = {
|
liveView: document.getElementById("sat-live-view"),
|
||||||
status: document.getElementById("sat-status"),
|
historyView: document.getElementById("sat-history-view"),
|
||||||
liveView: document.getElementById("sat-live-view"),
|
predictionsView: document.getElementById("sat-predictions-view"),
|
||||||
historyView: document.getElementById("sat-history-view"),
|
liveLatest: document.getElementById("sat-live-latest"),
|
||||||
predictionsView: document.getElementById("sat-predictions-view"),
|
historyList: document.getElementById("sat-history-list"),
|
||||||
liveLatest: document.getElementById("sat-live-latest"),
|
historyCount: document.getElementById("sat-history-count"),
|
||||||
historyList: document.getElementById("sat-history-list"),
|
filterInput: document.getElementById("sat-filter"),
|
||||||
historyCount: document.getElementById("sat-history-count"),
|
sortSelect: document.getElementById("sat-sort"),
|
||||||
filterInput: document.getElementById("sat-filter"),
|
typeFilter: document.getElementById("sat-type-filter"),
|
||||||
sortSelect: document.getElementById("sat-sort"),
|
lrptState: document.getElementById("sat-lrpt-state"),
|
||||||
typeFilter: document.getElementById("sat-type-filter"),
|
viewLiveBtn: document.getElementById("sat-view-live"),
|
||||||
lrptState: document.getElementById("sat-lrpt-state"),
|
viewHistoryBtn: document.getElementById("sat-view-history"),
|
||||||
viewLiveBtn: document.getElementById("sat-view-live"),
|
viewPredBtn: document.getElementById("sat-view-predictions"),
|
||||||
viewHistoryBtn: document.getElementById("sat-view-history"),
|
predFilter: document.getElementById("sat-pred-filter"),
|
||||||
viewPredBtn: document.getElementById("sat-view-predictions"),
|
predMinEl: document.getElementById("sat-pred-min-el"),
|
||||||
predFilter: document.getElementById("sat-pred-filter"),
|
predCategory: document.getElementById("sat-pred-category"),
|
||||||
predMinEl: document.getElementById("sat-pred-min-el"),
|
predCurrentList: document.getElementById("sat-pred-current-list"),
|
||||||
predCategory: document.getElementById("sat-pred-category"),
|
predUpcomingList: document.getElementById("sat-pred-list"),
|
||||||
predCurrentList: document.getElementById("sat-pred-current-list"),
|
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
||||||
predUpcomingList: document.getElementById("sat-pred-list"),
|
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
||||||
predCurrentSec: document.getElementById("sat-pred-current-section"),
|
predStatus: document.getElementById("sat-pred-status")
|
||||||
predUpcomingSec: document.getElementById("sat-pred-upcoming-section"),
|
};
|
||||||
predStatus: document.getElementById("sat-pred-status")
|
var satImageHistory = [];
|
||||||
};
|
var SAT_MAX_IMAGES = 100;
|
||||||
var satImageHistory = [];
|
var SAT_PRED_PAGE_SIZE = 50;
|
||||||
var SAT_MAX_IMAGES = 100;
|
var satPredShowAll = false;
|
||||||
var SAT_PRED_PAGE_SIZE = 50;
|
var satFilterText = "";
|
||||||
var satPredShowAll = false;
|
var satActiveView = "live";
|
||||||
var satFilterText = "";
|
var satPredData = [];
|
||||||
var satActiveView = "live";
|
var satPredFilterText = "";
|
||||||
var satPredData = [];
|
var satPredMinEl = 0;
|
||||||
var satPredFilterText = "";
|
var satPredCategory = "all";
|
||||||
var satPredMinEl = 0;
|
var satPredSatCount = 0;
|
||||||
var satPredCategory = "all";
|
var satPredCountdownTimer = null;
|
||||||
var satPredSatCount = 0;
|
function scheduleSatUi(key, job) {
|
||||||
var satPredCountdownTimer = null;
|
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
||||||
function scheduleSatUi(key, job) {
|
satWindow.trxScheduleUiFrameJob(key, job);
|
||||||
if (typeof satWindow.trxScheduleUiFrameJob === "function") {
|
return;
|
||||||
satWindow.trxScheduleUiFrameJob(key, job);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
job();
|
|
||||||
}
|
}
|
||||||
function switchSatView(view) {
|
job();
|
||||||
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
}
|
||||||
satActiveView = view;
|
function switchSatView(view) {
|
||||||
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
const leavingPredictions = satActiveView === "predictions" && view !== "predictions";
|
||||||
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
satActiveView = view;
|
||||||
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none";
|
||||||
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none";
|
||||||
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none";
|
||||||
if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions");
|
if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live");
|
||||||
if (leavingPredictions) clearPredictionDom();
|
if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history");
|
||||||
if (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;
|
||||||
|
void loadSatPredictions();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function clearPredictionDom() {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
}
|
||||||
|
satWindow.clearSatPredictionDom = clearPredictionDom;
|
||||||
|
satDom.viewLiveBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("live");
|
||||||
|
});
|
||||||
|
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("history");
|
||||||
|
});
|
||||||
|
satDom.viewPredBtn?.addEventListener("click", () => {
|
||||||
|
switchSatView("predictions");
|
||||||
|
});
|
||||||
|
var lastSatLrptOn = null;
|
||||||
|
satWindow.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];
|
||||||
|
if (!img) return;
|
||||||
|
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() : "";
|
||||||
|
const 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 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 (const image of items) {
|
||||||
|
fragment.appendChild(renderSatHistoryRow(image));
|
||||||
|
}
|
||||||
|
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();
|
renderSatHistoryTable();
|
||||||
} else if (view === "predictions") {
|
|
||||||
satPredShowAll = false;
|
|
||||||
void loadSatPredictions();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function clearPredictionDom() {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
}
|
|
||||||
satWindow.clearSatPredictionDom = clearPredictionDom;
|
|
||||||
satDom.viewLiveBtn?.addEventListener("click", () => {
|
|
||||||
switchSatView("live");
|
|
||||||
});
|
|
||||||
satDom.viewHistoryBtn?.addEventListener("click", () => {
|
|
||||||
switchSatView("history");
|
|
||||||
});
|
|
||||||
satDom.viewPredBtn?.addEventListener("click", () => {
|
|
||||||
switchSatView("predictions");
|
|
||||||
});
|
|
||||||
var lastSatLrptOn = null;
|
|
||||||
satWindow.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];
|
|
||||||
if (!img) return;
|
|
||||||
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() : "";
|
|
||||||
const 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 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 (const image of items) {
|
|
||||||
fragment.appendChild(renderSatHistoryRow(image));
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function onServerLrptProgress(msg) {
|
}
|
||||||
if (satDom.status && (msg.mcu_count ?? 0) > 0) {
|
function onServerLrptProgress(msg) {
|
||||||
satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
|
if (satDom.status && (msg.mcu_count ?? 0) > 0) {
|
||||||
}
|
satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`;
|
||||||
}
|
}
|
||||||
function onServerLrptImage(msg) {
|
}
|
||||||
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
function onServerLrptImage(msg) {
|
||||||
addSatImage(msg, "lrpt");
|
if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)";
|
||||||
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
|
addSatImage(msg, "lrpt");
|
||||||
satWindow.addSatMapOverlay(msg);
|
if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) {
|
||||||
}
|
satWindow.addSatMapOverlay(msg);
|
||||||
}
|
}
|
||||||
function resetSatHistoryView() {
|
}
|
||||||
satImageHistory = [];
|
function resetSatHistoryView() {
|
||||||
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
satImageHistory = [];
|
||||||
renderSatLatestCard();
|
if (satDom.historyList) satDom.historyList.innerHTML = "";
|
||||||
renderSatHistoryTable();
|
|
||||||
satWindow.clearSatMapOverlays?.();
|
|
||||||
}
|
|
||||||
function pruneSatHistoryView() {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
renderSatLatestCard();
|
|
||||||
}
|
|
||||||
satWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "lrpt_image",
|
|
||||||
onMessage: onServerLrptImage,
|
|
||||||
reset: resetSatHistoryView,
|
|
||||||
prune: pruneSatHistoryView
|
|
||||||
});
|
|
||||||
satWindow.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "lrpt_progress",
|
|
||||||
onMessage: onServerLrptProgress
|
|
||||||
});
|
|
||||||
var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
|
||||||
lrptDecodeToggleBtn?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
|
||||||
await satWindow.postPath?.("/toggle_lrpt_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("LRPT toggle failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
var satFilterInput = satDom.filterInput;
|
|
||||||
satFilterInput?.addEventListener("input", () => {
|
|
||||||
satFilterText = satFilterInput.value.trim().toUpperCase();
|
|
||||||
renderSatHistoryTable();
|
|
||||||
});
|
|
||||||
satDom.sortSelect?.addEventListener("change", () => {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
});
|
|
||||||
satDom.typeFilter?.addEventListener("change", () => {
|
|
||||||
renderSatHistoryTable();
|
|
||||||
});
|
|
||||||
document.getElementById("settings-clear-sat-history")?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await satWindow.postPath?.("/clear_lrpt_decode");
|
|
||||||
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] ?? "N";
|
|
||||||
}
|
|
||||||
function formatPredTime(ms) {
|
|
||||||
const d = new Date(ms);
|
|
||||||
const now = /* @__PURE__ */ new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
|
||||||
const hh = String(d.getUTCHours()).padStart(2, "0");
|
|
||||||
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
|
||||||
return `${day}${hh}:${mm}`;
|
|
||||||
}
|
|
||||||
function formatPredDuration(s) {
|
|
||||||
if (s >= 60) return `${Math.round(s / 60)} min`;
|
|
||||||
return `${s}s`;
|
|
||||||
}
|
|
||||||
function formatCountdown(ms) {
|
|
||||||
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
|
||||||
const m = Math.floor(totalSec / 60);
|
|
||||||
const s = totalSec % 60;
|
|
||||||
return `${m}:${String(s).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
function elevationClass(deg) {
|
|
||||||
if (deg >= 45) return "sat-pred-el-high";
|
|
||||||
if (deg >= 10) return "sat-pred-el-mid";
|
|
||||||
return "sat-pred-el-low";
|
|
||||||
}
|
|
||||||
function stopCountdownTimer() {
|
|
||||||
if (satPredCountdownTimer) {
|
|
||||||
clearInterval(satPredCountdownTimer);
|
|
||||||
satPredCountdownTimer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function startCountdownTimer(container) {
|
|
||||||
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
|
||||||
if (countdownEls.length === 0) return;
|
|
||||||
satPredCountdownTimer = setInterval(() => {
|
|
||||||
if (satActiveView !== "predictions") {
|
|
||||||
stopCountdownTimer();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const n = Date.now();
|
|
||||||
let anyActive = false;
|
|
||||||
for (const el of countdownEls) {
|
|
||||||
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
|
||||||
const rem = los - n;
|
|
||||||
if (rem > 0) {
|
|
||||||
el.textContent = formatCountdown(rem);
|
|
||||||
anyActive = true;
|
|
||||||
} else {
|
|
||||||
el.textContent = "0:00";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!anyActive) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
}, 1e3);
|
|
||||||
}
|
|
||||||
function buildCurrentPassRow(pass, now) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row-current";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
const remaining = Math.max(0, pass.los_ms - now);
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function buildUpcomingPassRow(pass) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "sat-pred-row";
|
|
||||||
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
|
||||||
row.innerHTML = [
|
|
||||||
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
|
||||||
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
|
||||||
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
|
||||||
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
|
||||||
`<span class="sat-pred-col-dir">${dir}</span>`
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function getFilteredPredictions() {
|
|
||||||
let items = satPredData;
|
|
||||||
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
|
||||||
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
|
||||||
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
function applyPredFilters() {
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
var satPredictionFilter = satDom.predFilter;
|
|
||||||
satPredictionFilter?.addEventListener("input", () => {
|
|
||||||
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
var satPredictionMinElevation = satDom.predMinEl;
|
|
||||||
satPredictionMinElevation?.addEventListener("change", () => {
|
|
||||||
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
var satPredictionCategory = satDom.predCategory;
|
|
||||||
satPredictionCategory?.addEventListener("change", () => {
|
|
||||||
satPredCategory = satPredictionCategory.value;
|
|
||||||
applyPredFilters();
|
|
||||||
});
|
|
||||||
function renderSatPredictions(passes, error) {
|
|
||||||
stopCountdownTimer();
|
|
||||||
if (error) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!Array.isArray(passes) || passes.length === 0) {
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const now = Date.now();
|
|
||||||
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
|
||||||
const upcoming = passes.filter((p) => p.aos_ms > now);
|
|
||||||
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predCurrentList) {
|
|
||||||
if (current.length === 0) {
|
|
||||||
satDom.predCurrentList.innerHTML = "";
|
|
||||||
} else {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
|
||||||
satDom.predCurrentList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
|
||||||
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
|
||||||
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
|
||||||
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
|
||||||
if (satDom.predUpcomingList) {
|
|
||||||
const frag = document.createDocumentFragment();
|
|
||||||
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
|
||||||
if (hiddenCount > 0) {
|
|
||||||
const moreRow = document.createElement("div");
|
|
||||||
moreRow.className = "sat-pred-row";
|
|
||||||
moreRow.style.cursor = "pointer";
|
|
||||||
moreRow.style.textAlign = "center";
|
|
||||||
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
|
||||||
moreRow.addEventListener("click", () => {
|
|
||||||
satPredShowAll = true;
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
});
|
|
||||||
frag.appendChild(moreRow);
|
|
||||||
}
|
|
||||||
satDom.predUpcomingList.replaceChildren(frag);
|
|
||||||
}
|
|
||||||
if (satDom.predStatus) {
|
|
||||||
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
|
||||||
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
|
||||||
satDom.predStatus.textContent = text;
|
|
||||||
}
|
|
||||||
if (current.length > 0 && satActiveView === "predictions") {
|
|
||||||
startCountdownTimer(satDom.predCurrentList);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async function loadSatPredictions() {
|
|
||||||
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
|
||||||
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
|
||||||
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
|
||||||
try {
|
|
||||||
const resp = await fetch("/sat_passes");
|
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
|
||||||
const data = await resp.json();
|
|
||||||
satPredSatCount = data.satellite_count || 0;
|
|
||||||
if (data.error) {
|
|
||||||
satPredData = [];
|
|
||||||
renderSatPredictions([], data.error);
|
|
||||||
} else {
|
|
||||||
satPredData = data.passes || [];
|
|
||||||
renderSatPredictions(getFilteredPredictions());
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
satWindow.satShowOnMap = function(south, west, north, east) {
|
|
||||||
if (typeof satWindow.enableMapSourceFilter === "function") {
|
|
||||||
satWindow.enableMapSourceFilter("sat");
|
|
||||||
}
|
|
||||||
const lat = (south + north) / 2;
|
|
||||||
const lon = (west + east) / 2;
|
|
||||||
if (satWindow.navigateToAprsMap) {
|
|
||||||
satWindow.navigateToAprsMap(lat, lon);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
renderSatLatestCard();
|
renderSatLatestCard();
|
||||||
renderSatHistoryTable();
|
renderSatHistoryTable();
|
||||||
})();
|
satWindow.clearSatMapOverlays?.();
|
||||||
|
}
|
||||||
|
function pruneSatHistoryView() {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
renderSatLatestCard();
|
||||||
|
}
|
||||||
|
satWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "lrpt_image",
|
||||||
|
onMessage: onServerLrptImage,
|
||||||
|
reset: resetSatHistoryView,
|
||||||
|
prune: pruneSatHistoryView
|
||||||
|
});
|
||||||
|
satWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "lrpt_progress",
|
||||||
|
onMessage: onServerLrptProgress
|
||||||
|
});
|
||||||
|
var lrptDecodeToggleBtn = document.getElementById("lrpt-decode-toggle-btn");
|
||||||
|
lrptDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await satWindow.takeSchedulerControlForDecoderDisable?.(lrptDecodeToggleBtn);
|
||||||
|
await satWindow.postPath?.("/toggle_lrpt_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("LRPT toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
var satFilterInput = satDom.filterInput;
|
||||||
|
satFilterInput?.addEventListener("input", () => {
|
||||||
|
satFilterText = satFilterInput.value.trim().toUpperCase();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.sortSelect?.addEventListener("change", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
satDom.typeFilter?.addEventListener("change", () => {
|
||||||
|
renderSatHistoryTable();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-sat-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await satWindow.trxUi.confirm({ title: "Clear satellite history?", message: "All stored satellite decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await satWindow.postPath?.("/clear_lrpt_decode");
|
||||||
|
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] ?? "N";
|
||||||
|
}
|
||||||
|
function formatPredTime(ms) {
|
||||||
|
const d = new Date(ms);
|
||||||
|
const now = /* @__PURE__ */ new Date();
|
||||||
|
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const day = d.getUTCDay() !== now.getUTCDay() ? `${dayNames[d.getUTCDay()] ?? ""} ` : "";
|
||||||
|
const hh = String(d.getUTCHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getUTCMinutes()).padStart(2, "0");
|
||||||
|
return `${day}${hh}:${mm}`;
|
||||||
|
}
|
||||||
|
function formatPredDuration(s) {
|
||||||
|
if (s >= 60) return `${Math.round(s / 60)} min`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
function formatCountdown(ms) {
|
||||||
|
const totalSec = Math.max(0, Math.floor(ms / 1e3));
|
||||||
|
const m = Math.floor(totalSec / 60);
|
||||||
|
const s = totalSec % 60;
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
function elevationClass(deg) {
|
||||||
|
if (deg >= 45) return "sat-pred-el-high";
|
||||||
|
if (deg >= 10) return "sat-pred-el-mid";
|
||||||
|
return "sat-pred-el-low";
|
||||||
|
}
|
||||||
|
function stopCountdownTimer() {
|
||||||
|
if (satPredCountdownTimer) {
|
||||||
|
clearInterval(satPredCountdownTimer);
|
||||||
|
satPredCountdownTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startCountdownTimer(container) {
|
||||||
|
const countdownEls = container?.querySelectorAll(".sat-pred-col-countdown") ?? [];
|
||||||
|
if (countdownEls.length === 0) return;
|
||||||
|
satPredCountdownTimer = setInterval(() => {
|
||||||
|
if (satActiveView !== "predictions") {
|
||||||
|
stopCountdownTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const n = Date.now();
|
||||||
|
let anyActive = false;
|
||||||
|
for (const el of countdownEls) {
|
||||||
|
const los = Number.parseInt(el.dataset.los ?? "0", 10);
|
||||||
|
const rem = los - n;
|
||||||
|
if (rem > 0) {
|
||||||
|
el.textContent = formatCountdown(rem);
|
||||||
|
anyActive = true;
|
||||||
|
} else {
|
||||||
|
el.textContent = "0:00";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!anyActive) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
}, 1e3);
|
||||||
|
}
|
||||||
|
function buildCurrentPassRow(pass, now) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row-current";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
const remaining = Math.max(0, pass.los_ms - now);
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.los_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-countdown" data-los="${pass.los_ms}">${formatCountdown(remaining)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function buildUpcomingPassRow(pass) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "sat-pred-row";
|
||||||
|
const dir = `${azToCardinal(pass.azimuth_aos_deg)} → ${azToCardinal(pass.azimuth_los_deg)}`;
|
||||||
|
row.innerHTML = [
|
||||||
|
`<span class="sat-pred-col-time">${formatPredTime(pass.aos_ms)}</span>`,
|
||||||
|
`<span class="sat-pred-col-sat">${pass.satellite}</span>`,
|
||||||
|
`<span class="sat-pred-col-el ${elevationClass(pass.max_elevation_deg)}">${pass.max_elevation_deg.toFixed(1)}°</span>`,
|
||||||
|
`<span class="sat-pred-col-dur">${formatPredDuration(pass.duration_s)}</span>`,
|
||||||
|
`<span class="sat-pred-col-dir">${dir}</span>`
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function getFilteredPredictions() {
|
||||||
|
let items = satPredData;
|
||||||
|
if (satPredCategory !== "all") items = items.filter((p) => p.category === satPredCategory);
|
||||||
|
if (satPredMinEl > 0) items = items.filter((p) => p.max_elevation_deg >= satPredMinEl);
|
||||||
|
if (satPredFilterText) items = items.filter((p) => p.satellite.toUpperCase().includes(satPredFilterText));
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
function applyPredFilters() {
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
var satPredictionFilter = satDom.predFilter;
|
||||||
|
satPredictionFilter?.addEventListener("input", () => {
|
||||||
|
satPredFilterText = satPredictionFilter.value.trim().toUpperCase();
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionMinElevation = satDom.predMinEl;
|
||||||
|
satPredictionMinElevation?.addEventListener("change", () => {
|
||||||
|
satPredMinEl = Number.parseInt(satPredictionMinElevation.value, 10) || 0;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
var satPredictionCategory = satDom.predCategory;
|
||||||
|
satPredictionCategory?.addEventListener("change", () => {
|
||||||
|
satPredCategory = satPredictionCategory.value;
|
||||||
|
applyPredFilters();
|
||||||
|
});
|
||||||
|
function renderSatPredictions(passes, error) {
|
||||||
|
stopCountdownTimer();
|
||||||
|
if (error) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = error;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Array.isArray(passes) || passes.length === 0) {
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = "none";
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = "none";
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "No passes found in the next 24 hours.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const current = passes.filter((p) => p.aos_ms <= now && p.los_ms > now);
|
||||||
|
const upcoming = passes.filter((p) => p.aos_ms > now);
|
||||||
|
if (satDom.predCurrentSec) satDom.predCurrentSec.style.display = current.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predCurrentList) {
|
||||||
|
if (current.length === 0) {
|
||||||
|
satDom.predCurrentList.innerHTML = "";
|
||||||
|
} else {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of current) frag.appendChild(buildCurrentPassRow(pass, now));
|
||||||
|
satDom.predCurrentList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const upcomingLimit = satPredShowAll ? upcoming.length : SAT_PRED_PAGE_SIZE;
|
||||||
|
const visibleUpcoming = upcoming.slice(0, upcomingLimit);
|
||||||
|
const hiddenCount = upcoming.length - visibleUpcoming.length;
|
||||||
|
if (satDom.predUpcomingSec) satDom.predUpcomingSec.style.display = upcoming.length > 0 ? "" : "none";
|
||||||
|
if (satDom.predUpcomingList) {
|
||||||
|
const frag = document.createDocumentFragment();
|
||||||
|
for (const pass of visibleUpcoming) frag.appendChild(buildUpcomingPassRow(pass));
|
||||||
|
if (hiddenCount > 0) {
|
||||||
|
const moreRow = document.createElement("div");
|
||||||
|
moreRow.className = "sat-pred-row";
|
||||||
|
moreRow.style.cursor = "pointer";
|
||||||
|
moreRow.style.textAlign = "center";
|
||||||
|
moreRow.innerHTML = `<span style="grid-column:1/-1;color:var(--accent);font-size:0.82rem;">Show ${hiddenCount} more passes…</span>`;
|
||||||
|
moreRow.addEventListener("click", () => {
|
||||||
|
satPredShowAll = true;
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
});
|
||||||
|
frag.appendChild(moreRow);
|
||||||
|
}
|
||||||
|
satDom.predUpcomingList.replaceChildren(frag);
|
||||||
|
}
|
||||||
|
if (satDom.predStatus) {
|
||||||
|
let text = `${current.length} active · ${upcoming.length} upcoming · times in UTC`;
|
||||||
|
if (satPredSatCount > 0) text += ` · ${satPredSatCount} satellites tracked`;
|
||||||
|
satDom.predStatus.textContent = text;
|
||||||
|
}
|
||||||
|
if (current.length > 0 && satActiveView === "predictions") {
|
||||||
|
startCountdownTimer(satDom.predCurrentList);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadSatPredictions() {
|
||||||
|
if (satDom.predStatus) satDom.predStatus.textContent = "Loading predictions…";
|
||||||
|
if (satDom.predCurrentList) satDom.predCurrentList.innerHTML = "";
|
||||||
|
if (satDom.predUpcomingList) satDom.predUpcomingList.innerHTML = "";
|
||||||
|
try {
|
||||||
|
const resp = await fetch("/sat_passes");
|
||||||
|
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
satPredSatCount = data.satellite_count || 0;
|
||||||
|
if (data.error) {
|
||||||
|
satPredData = [];
|
||||||
|
renderSatPredictions([], data.error);
|
||||||
|
} else {
|
||||||
|
satPredData = data.passes || [];
|
||||||
|
renderSatPredictions(getFilteredPredictions());
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
renderSatPredictions([], `Failed to load predictions: ${error instanceof Error ? error.message : String(error)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
satWindow.satShowOnMap = function(south, west, north, east) {
|
||||||
|
if (typeof satWindow.enableMapSourceFilter === "function") {
|
||||||
|
satWindow.enableMapSourceFilter("sat");
|
||||||
|
}
|
||||||
|
const lat = (south + north) / 2;
|
||||||
|
const lon = (west + east) / 2;
|
||||||
|
if (satWindow.navigateToAprsMap) {
|
||||||
|
satWindow.navigateToAprsMap(lat, lon);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
renderSatLatestCard();
|
||||||
|
renderSatHistoryTable();
|
||||||
|
|||||||
+1176
-1179
File diff suppressed because it is too large
Load Diff
@@ -1,459 +1,456 @@
|
|||||||
"use strict";
|
// src/plugins/vchan.ts
|
||||||
(() => {
|
var vchanWindow = window;
|
||||||
// src/plugins/vchan.ts
|
var vchanSessionId = null;
|
||||||
var vchanWindow = window;
|
var vchanRigId = null;
|
||||||
var vchanSessionId = null;
|
var vchanChannels = [];
|
||||||
var vchanRigId = null;
|
var vchanActiveId = null;
|
||||||
var vchanChannels = [];
|
var schedulerReleaseState = null;
|
||||||
var vchanActiveId = null;
|
var schedulerReleasePollTimer = null;
|
||||||
var schedulerReleaseState = null;
|
function vchanFmtFreq(hz) {
|
||||||
var schedulerReleasePollTimer = null;
|
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
||||||
function vchanFmtFreq(hz) {
|
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
||||||
if (!Number.isFinite(hz) || hz <= 0) return "--";
|
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
||||||
if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz";
|
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
||||||
if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz";
|
return `${String(hz)} Hz`;
|
||||||
if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz";
|
}
|
||||||
return `${String(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.`;
|
||||||
}
|
}
|
||||||
function schedulerReleaseSummaryText(state) {
|
if (!state.current_session_released) {
|
||||||
if (!state) return "Scheduler is controlling the rig.";
|
const othersReleased = Math.max(released, 0);
|
||||||
const connected = Number(state.connected_sessions) || 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 released = Number(state.released_sessions) || 0;
|
}
|
||||||
if (connected === 0) return "Scheduler can control the rig.";
|
const blocking = Math.max(connected - released, 0);
|
||||||
if (state.all_released) {
|
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
|
||||||
return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`;
|
}
|
||||||
|
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(() => {
|
||||||
|
void vchanPollSchedulerRelease();
|
||||||
|
}, 1e4);
|
||||||
|
}
|
||||||
|
async function vchanToggleSchedulerRelease() {
|
||||||
|
if (!vchanSessionId) return;
|
||||||
|
const rigId = vchanRigId || vchanWindow.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
||||||
|
function vchanHandleSession(data) {
|
||||||
|
try {
|
||||||
|
const d = JSON.parse(data);
|
||||||
|
vchanSessionId = d.session_id || null;
|
||||||
|
void 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));
|
||||||
|
const primaryChannel = vchanChannels[0];
|
||||||
|
if (!vchanActiveId && primaryChannel && vchanSessionId) {
|
||||||
|
void vchanAutoJoinPrimary(primaryChannel.id);
|
||||||
|
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
||||||
|
vchanActiveId = vchanChannels[0]?.id ?? null;
|
||||||
|
vchanReconnectAudio();
|
||||||
}
|
}
|
||||||
if (!state.current_session_released) {
|
vchanRender();
|
||||||
const othersReleased = Math.max(released, 0);
|
vchanRenderSchedulerRelease();
|
||||||
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.";
|
vchanWindow.renderRdsOverlays?.();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("vchan: bad channels event", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vchanWindow.vchanHandleSession = vchanHandleSession;
|
||||||
|
vchanWindow.vchanHandleChannels = vchanHandleChannels;
|
||||||
|
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();
|
||||||
|
void vchanDelete(ch.id);
|
||||||
|
});
|
||||||
|
btn.appendChild(del);
|
||||||
}
|
}
|
||||||
const blocking = Math.max(connected - released, 0);
|
btn.addEventListener("click", () => {
|
||||||
return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig.";
|
if (ch.id !== vchanActiveId) void 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", () => {
|
||||||
|
void vchanAllocate();
|
||||||
|
});
|
||||||
|
picker.appendChild(addBtn);
|
||||||
|
vchanSyncAccentUI();
|
||||||
|
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
||||||
|
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
||||||
}
|
}
|
||||||
function vchanRenderSchedulerRelease() {
|
vchanRenderSchedulerRelease();
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
}
|
||||||
const status = document.getElementById("scheduler-release-status");
|
async function vchanAllocate() {
|
||||||
if (!btn || !status) return;
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released);
|
const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0;
|
||||||
btn.disabled = !vchanSessionId || currentReleased;
|
const modeEl = document.getElementById("mode");
|
||||||
btn.classList.toggle("active", !currentReleased);
|
const mode = modeEl ? modeEl.value || "USB" : "USB";
|
||||||
btn.textContent = "Release to Scheduler";
|
try {
|
||||||
status.textContent = schedulerReleaseSummaryText(schedulerReleaseState);
|
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
||||||
}
|
method: "POST",
|
||||||
async function vchanPollSchedulerRelease() {
|
headers: { "Content-Type": "application/json" },
|
||||||
if (!vchanSessionId) {
|
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
|
||||||
schedulerReleaseState = null;
|
});
|
||||||
vchanRenderSchedulerRelease();
|
if (!resp.ok) {
|
||||||
|
const msg = await resp.text().catch(() => String(resp.status));
|
||||||
|
console.warn("vchan: allocate failed —", msg);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
const ch = await resp.json();
|
||||||
const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`);
|
vchanActiveId = ch.id;
|
||||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
vchanRender();
|
||||||
schedulerReleaseState = await resp.json();
|
vchanReconnectAudio();
|
||||||
vchanRenderSchedulerRelease();
|
} catch (e) {
|
||||||
} catch (e) {
|
console.error("vchan: allocate error", e);
|
||||||
console.error("scheduler release status failed", e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function vchanStartSchedulerReleasePolling() {
|
}
|
||||||
if (schedulerReleasePollTimer) {
|
async function vchanDelete(channelId) {
|
||||||
clearInterval(schedulerReleasePollTimer);
|
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);
|
||||||
}
|
}
|
||||||
schedulerReleasePollTimer = setInterval(() => {
|
} catch (e) {
|
||||||
void vchanPollSchedulerRelease();
|
console.error("vchan: delete error", e);
|
||||||
}, 1e4);
|
|
||||||
}
|
}
|
||||||
async function vchanToggleSchedulerRelease() {
|
}
|
||||||
if (!vchanSessionId) return;
|
async function vchanAutoJoinPrimary(channelId) {
|
||||||
const rigId = vchanRigId || vchanWindow.lastActiveRigId || null;
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
try {
|
try {
|
||||||
const resp = await fetch("/scheduler-control", {
|
const resp = await fetch(
|
||||||
method: "PUT",
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vchanWindow.vchanTakeSchedulerControl = vchanTakeSchedulerControl;
|
|
||||||
function vchanHandleSession(data) {
|
|
||||||
try {
|
|
||||||
const d = JSON.parse(data);
|
|
||||||
vchanSessionId = d.session_id || null;
|
|
||||||
void 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));
|
|
||||||
const primaryChannel = vchanChannels[0];
|
|
||||||
if (!vchanActiveId && primaryChannel && vchanSessionId) {
|
|
||||||
void vchanAutoJoinPrimary(primaryChannel.id);
|
|
||||||
} else if (vchanActiveId && !ids.has(vchanActiveId)) {
|
|
||||||
vchanActiveId = vchanChannels[0]?.id ?? null;
|
|
||||||
vchanReconnectAudio();
|
|
||||||
}
|
|
||||||
vchanRender();
|
|
||||||
vchanRenderSchedulerRelease();
|
|
||||||
vchanWindow.renderRdsOverlays?.();
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("vchan: bad channels event", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vchanWindow.vchanHandleSession = vchanHandleSession;
|
|
||||||
vchanWindow.vchanHandleChannels = vchanHandleChannels;
|
|
||||||
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();
|
|
||||||
void vchanDelete(ch.id);
|
|
||||||
});
|
|
||||||
btn.appendChild(del);
|
|
||||||
}
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
if (ch.id !== vchanActiveId) void 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", () => {
|
|
||||||
void vchanAllocate();
|
|
||||||
});
|
|
||||||
picker.appendChild(addBtn);
|
|
||||||
vchanSyncAccentUI();
|
|
||||||
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
|
||||||
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
|
||||||
}
|
|
||||||
vchanRenderSchedulerRelease();
|
|
||||||
}
|
|
||||||
async function vchanAllocate() {
|
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
|
||||||
const freqHz = typeof vchanWindow.lastFreqHz === "number" && vchanWindow.lastFreqHz > 0 ? vchanWindow.lastFreqHz : 0;
|
|
||||||
const modeEl = document.getElementById("mode");
|
|
||||||
const mode = modeEl ? modeEl.value || "USB" : "USB";
|
|
||||||
try {
|
|
||||||
const resp = await fetch(`/channels/${encodeURIComponent(vchanRigId)}`, {
|
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode })
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
});
|
|
||||||
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;
|
if (!resp.ok) {
|
||||||
vchanRender();
|
console.warn("vchan: auto-join primary failed", resp.status);
|
||||||
vchanReconnectAudio();
|
return;
|
||||||
} catch (e) {
|
|
||||||
console.error("vchan: allocate error", e);
|
|
||||||
}
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: auto-join error", e);
|
||||||
}
|
}
|
||||||
async function vchanDelete(channelId) {
|
}
|
||||||
if (!vchanRigId) return;
|
async function vchanSubscribe(channelId) {
|
||||||
try {
|
if (!vchanSessionId || !vchanRigId) return;
|
||||||
const resp = await fetch(
|
try {
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`,
|
await vchanTakeSchedulerControl();
|
||||||
{ method: "DELETE" }
|
const resp = await fetch(
|
||||||
);
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
||||||
if (!resp.ok) {
|
{
|
||||||
console.warn("vchan: delete failed", resp.status);
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ session_id: vchanSessionId })
|
||||||
}
|
}
|
||||||
} catch (e) {
|
);
|
||||||
console.error("vchan: delete error", e);
|
if (!resp.ok) {
|
||||||
|
console.warn("vchan: subscribe failed", resp.status);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
vchanActiveId = channelId;
|
||||||
|
vchanRender();
|
||||||
|
vchanSyncModeDisplay();
|
||||||
|
vchanReconnectAudio();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("vchan: subscribe error", e);
|
||||||
}
|
}
|
||||||
async function vchanAutoJoinPrimary(channelId) {
|
}
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
function vchanReconnectAudio() {
|
||||||
try {
|
const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null;
|
||||||
const resp = await fetch(
|
vchanWindow._audioChannelOverride = ch?.id ?? null;
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`,
|
if (!vchanWindow.rxActive) return;
|
||||||
{
|
vchanWindow.stopRxAudio?.();
|
||||||
method: "POST",
|
setTimeout(() => {
|
||||||
headers: { "Content-Type": "application/json" },
|
vchanWindow.startRxAudio?.();
|
||||||
body: JSON.stringify({ session_id: vchanSessionId })
|
}, 300);
|
||||||
}
|
}
|
||||||
);
|
function vchanApplyCapabilities(caps) {
|
||||||
if (!resp.ok) {
|
const picker = document.getElementById("vchan-picker");
|
||||||
console.warn("vchan: auto-join primary failed", resp.status);
|
if (!picker) return;
|
||||||
return;
|
picker.style.display = caps && caps.filter_controls ? "" : "none";
|
||||||
}
|
vchanRenderSchedulerRelease();
|
||||||
vchanActiveId = channelId;
|
}
|
||||||
vchanRender();
|
vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities;
|
||||||
} catch (e) {
|
function vchanIsOnVirtual() {
|
||||||
console.error("vchan: auto-join error", e);
|
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
||||||
}
|
return vchanActiveId !== vchanChannels[0]?.id;
|
||||||
|
}
|
||||||
|
vchanWindow.vchanIsOnVirtual = vchanIsOnVirtual;
|
||||||
|
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 (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
|
||||||
|
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
|
||||||
|
} else {
|
||||||
|
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
||||||
}
|
}
|
||||||
async function vchanSubscribe(channelId) {
|
}
|
||||||
if (!vchanSessionId || !vchanRigId) return;
|
function vchanSyncModeDisplay() {
|
||||||
try {
|
const modeEl = document.getElementById("mode");
|
||||||
await vchanTakeSchedulerControl();
|
if (!modeEl) return;
|
||||||
const resp = await fetch(
|
if (vchanIsOnVirtual()) {
|
||||||
`/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;
|
|
||||||
vchanWindow._audioChannelOverride = ch?.id ?? null;
|
|
||||||
if (!vchanWindow.rxActive) return;
|
|
||||||
vchanWindow.stopRxAudio?.();
|
|
||||||
setTimeout(() => {
|
|
||||||
vchanWindow.startRxAudio?.();
|
|
||||||
}, 300);
|
|
||||||
}
|
|
||||||
function vchanApplyCapabilities(caps) {
|
|
||||||
const picker = document.getElementById("vchan-picker");
|
|
||||||
if (!picker) return;
|
|
||||||
picker.style.display = caps && caps.filter_controls ? "" : "none";
|
|
||||||
vchanRenderSchedulerRelease();
|
|
||||||
}
|
|
||||||
vchanWindow.vchanApplyCapabilities = vchanApplyCapabilities;
|
|
||||||
function vchanIsOnVirtual() {
|
|
||||||
if (!vchanActiveId || vchanChannels.length === 0) return false;
|
|
||||||
return vchanActiveId !== vchanChannels[0]?.id;
|
|
||||||
}
|
|
||||||
vchanWindow.vchanIsOnVirtual = vchanIsOnVirtual;
|
|
||||||
function vchanActiveChannel() {
|
|
||||||
return vchanChannels.find((c) => c.id === vchanActiveId) || null;
|
|
||||||
}
|
|
||||||
function vchanUpdateFreqDisplay() {
|
|
||||||
const ch = vchanActiveChannel();
|
const ch = vchanActiveChannel();
|
||||||
if (!ch) return;
|
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
||||||
const el = document.getElementById("freq");
|
|
||||||
if (!el) return;
|
|
||||||
if (vchanWindow.formatFreqForStep && typeof vchanWindow.jogUnit === "number") {
|
|
||||||
el.value = vchanWindow.formatFreqForStep(ch.freq_hz, vchanWindow.jogUnit);
|
|
||||||
} else {
|
|
||||||
el.value = (ch.freq_hz / 1e6).toFixed(6).replace(/\.?0+$/, "");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function vchanSyncModeDisplay() {
|
const modeUpper = (modeEl.value || "").toUpperCase();
|
||||||
const modeEl = document.getElementById("mode");
|
if (typeof vchanWindow.lastModeName === "string") {
|
||||||
if (!modeEl) return;
|
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
|
||||||
if (vchanIsOnVirtual()) {
|
vchanWindow.setJogDivisor?.(10);
|
||||||
const ch = vchanActiveChannel();
|
vchanWindow.resetRdsDisplay?.();
|
||||||
if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase();
|
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
|
||||||
|
vchanWindow.resetRdsDisplay?.();
|
||||||
}
|
}
|
||||||
const modeUpper = (modeEl.value || "").toUpperCase();
|
vchanWindow.lastModeName = modeUpper;
|
||||||
if (typeof vchanWindow.lastModeName === "string") {
|
}
|
||||||
if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") {
|
vchanWindow.updateWfmControls?.();
|
||||||
vchanWindow.setJogDivisor?.(10);
|
vchanWindow.updateSdrSquelchControlVisibility?.();
|
||||||
vchanWindow.resetRdsDisplay?.();
|
if (vchanWindow.refreshRdsUi) {
|
||||||
} else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") {
|
vchanWindow.refreshRdsUi();
|
||||||
vchanWindow.resetRdsDisplay?.();
|
} else {
|
||||||
|
vchanWindow.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 && vchanWindow.mwDefaultsForMode) {
|
||||||
|
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
|
||||||
|
}
|
||||||
|
if (bwHz > 0) {
|
||||||
|
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
||||||
|
vchanWindow.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 {
|
||||||
|
origRefreshFreqDisplay?.();
|
||||||
|
}
|
||||||
|
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
||||||
|
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var origRefreshFreqDisplay = null;
|
||||||
|
function vchanSetChannelFreq(freqHz) {
|
||||||
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
|
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
|
||||||
|
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
|
||||||
|
const center = vchanWindow.lastSpectrumData.center_hz;
|
||||||
|
if (Math.abs(freqHz - center) > halfSpan) {
|
||||||
|
if (vchanWindow.showHint) {
|
||||||
|
vchanWindow.showHint(
|
||||||
|
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
||||||
|
3e3
|
||||||
|
);
|
||||||
}
|
}
|
||||||
vchanWindow.lastModeName = modeUpper;
|
return;
|
||||||
}
|
|
||||||
vchanWindow.updateWfmControls?.();
|
|
||||||
vchanWindow.updateSdrSquelchControlVisibility?.();
|
|
||||||
if (vchanWindow.refreshRdsUi) {
|
|
||||||
vchanWindow.refreshRdsUi();
|
|
||||||
} else {
|
|
||||||
vchanWindow.positionRdsPsOverlay?.();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function vchanSyncBwDisplay() {
|
void vchanTakeSchedulerControl();
|
||||||
if (!vchanIsOnVirtual()) return;
|
void fetch(
|
||||||
const ch = vchanActiveChannel();
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
||||||
if (!ch) return;
|
{
|
||||||
const bwEl = document.getElementById("spectrum-bw-input");
|
method: "PUT",
|
||||||
if (!bwEl) return;
|
headers: { "Content-Type": "application/json" },
|
||||||
let bwHz = ch.bandwidth_hz || 0;
|
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
|
||||||
if (bwHz === 0 && vchanWindow.mwDefaultsForMode) {
|
|
||||||
bwHz = vchanWindow.mwDefaultsForMode(ch.mode)[0] || 0;
|
|
||||||
}
|
}
|
||||||
if (bwHz > 0) {
|
).catch((error) => {
|
||||||
bwEl.value = (bwHz / 1e3).toFixed(3).replace(/\.?0+$/, "");
|
console.error("vchan: set freq error", error);
|
||||||
vchanWindow.currentBandwidthHz = bwHz;
|
});
|
||||||
}
|
}
|
||||||
}
|
async function vchanSetChannelBandwidth(bwHz) {
|
||||||
function vchanSyncAccentUI() {
|
if (!vchanRigId || !vchanActiveId) return;
|
||||||
const onVirtual = vchanIsOnVirtual();
|
try {
|
||||||
const freqEl = document.getElementById("freq");
|
await vchanTakeSchedulerControl();
|
||||||
const bwEl = document.getElementById("spectrum-bw-input");
|
const resp = await fetch(
|
||||||
if (freqEl) freqEl.classList.toggle("vchan-ch-active", onVirtual);
|
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
||||||
if (bwEl) bwEl.classList.toggle("vchan-ch-active", onVirtual);
|
|
||||||
if (onVirtual) {
|
|
||||||
vchanUpdateFreqDisplay();
|
|
||||||
vchanSyncModeDisplay();
|
|
||||||
vchanSyncBwDisplay();
|
|
||||||
} else {
|
|
||||||
origRefreshFreqDisplay?.();
|
|
||||||
}
|
|
||||||
if (vchanWindow.updateDocumentTitle && vchanWindow.activeChannelRds) {
|
|
||||||
vchanWindow.updateDocumentTitle(vchanWindow.activeChannelRds());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
var origRefreshFreqDisplay = null;
|
|
||||||
function vchanSetChannelFreq(freqHz) {
|
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
|
||||||
if (vchanWindow.lastSpectrumData && vchanWindow.lastSpectrumData.sample_rate > 0) {
|
|
||||||
const halfSpan = vchanWindow.lastSpectrumData.sample_rate / 2;
|
|
||||||
const center = vchanWindow.lastSpectrumData.center_hz;
|
|
||||||
if (Math.abs(freqHz - center) > halfSpan) {
|
|
||||||
if (vchanWindow.showHint) {
|
|
||||||
vchanWindow.showHint(
|
|
||||||
`Out of SDR bandwidth (center ${(center / 1e6).toFixed(3)} MHz ±${(halfSpan / 1e3).toFixed(0)} kHz)`,
|
|
||||||
3e3
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
void vchanTakeSchedulerControl();
|
|
||||||
void fetch(
|
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`,
|
|
||||||
{
|
{
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ freq_hz: Math.round(freqHz) })
|
body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) })
|
||||||
}
|
}
|
||||||
).catch((error) => {
|
);
|
||||||
console.error("vchan: set freq error", error);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
vchanWindow.vchanInterceptMode = async function(mode) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelMode(mode);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
vchanWindow.vchanInterceptBandwidth = async function(bwHz) {
|
||||||
|
if (!vchanIsOnVirtual()) return false;
|
||||||
|
await vchanSetChannelBandwidth(bwHz);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
(function() {
|
||||||
|
const original = vchanWindow.setRigFrequency;
|
||||||
|
vchanWindow.setRigFrequency = function(freqHz) {
|
||||||
|
if (vchanIsOnVirtual()) {
|
||||||
|
if (vchanWindow.applyLocalTunedFrequency) {
|
||||||
|
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
||||||
|
vchanWindow._freqOptimisticSeq += 1;
|
||||||
|
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
||||||
|
}
|
||||||
|
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
||||||
|
}
|
||||||
|
vchanSetChannelFreq(freqHz);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void vchanTakeSchedulerControl();
|
||||||
|
original?.(freqHz);
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
(function initSchedulerReleaseControl() {
|
||||||
|
const btn = document.getElementById("scheduler-release-btn");
|
||||||
|
if (btn) {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
void vchanToggleSchedulerRelease();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function vchanSetChannelBandwidth(bwHz) {
|
vchanStartSchedulerReleasePolling();
|
||||||
if (!vchanRigId || !vchanActiveId) return;
|
vchanRenderSchedulerRelease();
|
||||||
try {
|
})();
|
||||||
await vchanTakeSchedulerControl();
|
(function() {
|
||||||
const resp = await fetch(
|
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
|
||||||
`/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`,
|
vchanWindow.refreshFreqDisplay = function() {
|
||||||
{
|
if (vchanIsOnVirtual()) {
|
||||||
method: "PUT",
|
vchanUpdateFreqDisplay();
|
||||||
headers: { "Content-Type": "application/json" },
|
return;
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
origRefreshFreqDisplay?.();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
vchanWindow.vchanInterceptMode = async function(mode) {
|
|
||||||
if (!vchanIsOnVirtual()) return false;
|
|
||||||
await vchanSetChannelMode(mode);
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
vchanWindow.vchanInterceptBandwidth = async function(bwHz) {
|
|
||||||
if (!vchanIsOnVirtual()) return false;
|
|
||||||
await vchanSetChannelBandwidth(bwHz);
|
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
(function() {
|
|
||||||
const original = vchanWindow.setRigFrequency;
|
|
||||||
vchanWindow.setRigFrequency = function(freqHz) {
|
|
||||||
if (vchanIsOnVirtual()) {
|
|
||||||
if (vchanWindow.applyLocalTunedFrequency) {
|
|
||||||
if (typeof vchanWindow._freqOptimisticSeq === "number") {
|
|
||||||
vchanWindow._freqOptimisticSeq += 1;
|
|
||||||
vchanWindow._freqOptimisticHz = Math.round(freqHz);
|
|
||||||
}
|
|
||||||
vchanWindow.applyLocalTunedFrequency(Math.round(freqHz));
|
|
||||||
}
|
|
||||||
vchanSetChannelFreq(freqHz);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void vchanTakeSchedulerControl();
|
|
||||||
original?.(freqHz);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
(function initSchedulerReleaseControl() {
|
|
||||||
const btn = document.getElementById("scheduler-release-btn");
|
|
||||||
if (btn) {
|
|
||||||
btn.addEventListener("click", () => {
|
|
||||||
void vchanToggleSchedulerRelease();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
vchanStartSchedulerReleasePolling();
|
|
||||||
vchanRenderSchedulerRelease();
|
|
||||||
})();
|
|
||||||
(function() {
|
|
||||||
origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null;
|
|
||||||
vchanWindow.refreshFreqDisplay = function() {
|
|
||||||
if (vchanIsOnVirtual()) {
|
|
||||||
vchanUpdateFreqDisplay();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
origRefreshFreqDisplay?.();
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -1,271 +1,268 @@
|
|||||||
"use strict";
|
// src/plugins/vdes.ts
|
||||||
(() => {
|
var vdesWindow = window;
|
||||||
// src/plugins/vdes.ts
|
var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
var vdesWindow = window;
|
var vdesStatus = document.getElementById("vdes-status");
|
||||||
var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
var vdesMessagesEl = document.getElementById("vdes-messages");
|
||||||
var vdesStatus = document.getElementById("vdes-status");
|
var vdesFilterInput = document.getElementById("vdes-filter");
|
||||||
var vdesMessagesEl = document.getElementById("vdes-messages");
|
var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
||||||
var vdesFilterInput = document.getElementById("vdes-filter");
|
var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
||||||
var vdesBarOverlay = document.getElementById("vdes-bar-overlay");
|
var vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
||||||
var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary");
|
var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
||||||
var vdesFrameCountEl = document.getElementById("vdes-frame-count");
|
var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
||||||
var vdesLatestSeenEl = document.getElementById("vdes-latest-seen");
|
var vdesFilterText = "";
|
||||||
var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
var vdesMessageHistory = [];
|
||||||
var vdesFilterText = "";
|
function currentVdesHistoryRetentionMs() {
|
||||||
var vdesMessageHistory = [];
|
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
function currentVdesHistoryRetentionMs() {
|
}
|
||||||
return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
function pruneVdesMessageHistory() {
|
||||||
|
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
||||||
|
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
|
}
|
||||||
|
function scheduleVdesUi(key, job) {
|
||||||
|
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
vdesWindow.trxScheduleUiFrameJob(key, job);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function pruneVdesMessageHistory() {
|
job();
|
||||||
const cutoffMs = Date.now() - currentVdesHistoryRetentionMs();
|
}
|
||||||
vdesMessageHistory = vdesMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
function scheduleVdesHistoryRender() {
|
||||||
}
|
scheduleVdesUi("vdes-history", () => {
|
||||||
function scheduleVdesUi(key, job) {
|
|
||||||
if (typeof vdesWindow.trxScheduleUiFrameJob === "function") {
|
|
||||||
vdesWindow.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 (typeof tsMs !== "number" || !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) => 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 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">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(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 ?? 0) >= 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 = escapeVdesHtml(msg.callsign || "VDES");
|
|
||||||
const title = escapeVdesHtml(msg.vessel_name || "Burst");
|
|
||||||
const detail = [
|
|
||||||
`${msg.bit_len || 0} bits`,
|
|
||||||
msg.message_label ? escapeVdesHtml(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 ? escapeVdesHtml(msg.destination) : null,
|
|
||||||
escapeVdesHtml(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";
|
|
||||||
}
|
|
||||||
vdesWindow.updateVdesBar = updateVdesBar;
|
|
||||||
vdesWindow.clearVdesBar = function() {
|
|
||||||
resetVdesHistoryView();
|
|
||||||
};
|
|
||||||
function resetVdesHistoryView() {
|
|
||||||
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
|
||||||
vdesMessageHistory = [];
|
|
||||||
updateVdesBar();
|
|
||||||
renderVdesHistory();
|
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 (typeof tsMs !== "number" || !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) => value.toString(16).padStart(2, "0")).join(" ").toUpperCase();
|
||||||
|
}
|
||||||
|
function updateVdesSummary() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (vdesChannelSummaryEl) {
|
||||||
|
vdesChannelSummaryEl.textContent = currentVdesCenterText();
|
||||||
}
|
}
|
||||||
function renderVdesHistory() {
|
if (vdesFrameCountEl) {
|
||||||
pruneVdesMessageHistory();
|
const count = vdesMessageHistory.length;
|
||||||
if (!vdesMessagesEl) {
|
vdesFrameCountEl.textContent = `${count} burst${count === 1 ? "" : "s"}`;
|
||||||
updateVdesSummary();
|
}
|
||||||
return;
|
if (vdesLatestSeenEl) {
|
||||||
}
|
const latest = vdesMessageHistory[0];
|
||||||
const fragment = document.createDocumentFragment();
|
vdesLatestSeenEl.textContent = latest ? vdesAgeText(latest._tsMs) : "No traffic yet";
|
||||||
for (const message of vdesMessageHistory) {
|
}
|
||||||
fragment.appendChild(renderVdesRow(message));
|
}
|
||||||
}
|
function applyVdesFilterToRow(row) {
|
||||||
vdesMessagesEl.replaceChildren(fragment);
|
if (!vdesFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const text = row.dataset.filterText || "";
|
||||||
|
row.style.display = text.includes(vdesFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
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">${escapeVdesHtml(title)}</span><span class="vdes-badge">${escapeVdesHtml(label)}</span>` + (labelText ? `<span class="vdes-badge">${escapeVdesHtml(labelText)}</span>` : "") + (linkText ? `<span class="vdes-badge">${escapeVdesHtml(linkText)}</span>` : "") + (srcText ? `<span class="vdes-badge">${escapeVdesHtml(srcText)}</span>` : "") + (dstText ? `<span class="vdes-badge">${escapeVdesHtml(dstText)}</span>` : "") + (syncText ? `<span class="vdes-badge">${escapeVdesHtml(syncText)}</span>` : "") + (phaseText ? `<span class="vdes-badge">${escapeVdesHtml(phaseText)}</span>` : "") + `<span class="vdes-badge">T${escapeVdesHtml(String(msg.message_type ?? "--"))}</span></div><div class="vdes-row-meta"><span>${escapeVdesHtml(currentVdesCenterText())}</span><span>${escapeVdesHtml(`${msg.bit_len || 0} bits`)}</span>` + (sessionText ? `<span>${escapeVdesHtml(sessionText)}</span>` : "") + (asmText ? `<span>${escapeVdesHtml(asmText)}</span>` : "") + (countText ? `<span>${escapeVdesHtml(countText)}</span>` : "") + (ackText ? `<span>${escapeVdesHtml(ackText)}</span>` : "") + (cqiText ? `<span>${escapeVdesHtml(cqiText)}</span>` : "") + (info ? `<span>${escapeVdesHtml(info)}</span>` : "") + (fecText ? `<span>${escapeVdesHtml(fecText)}</span>` : "") + `<span>${escapeVdesHtml(vdesAgeText(msg._tsMs))}</span></div><div class="vdes-row-detail">` + (previewText ? `<span>${escapeVdesHtml(previewText)}</span>` : "") + (previewText ? `<span>·</span>` : "") + `<span class="vdes-raw">${escapeVdesHtml(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 ?? 0) >= 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 = escapeVdesHtml(msg.callsign || "VDES");
|
||||||
|
const title = escapeVdesHtml(msg.vessel_name || "Burst");
|
||||||
|
const detail = [
|
||||||
|
`${msg.bit_len || 0} bits`,
|
||||||
|
msg.message_label ? escapeVdesHtml(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 ? escapeVdesHtml(msg.destination) : null,
|
||||||
|
escapeVdesHtml(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";
|
||||||
|
}
|
||||||
|
vdesWindow.updateVdesBar = updateVdesBar;
|
||||||
|
vdesWindow.clearVdesBar = function() {
|
||||||
|
resetVdesHistoryView();
|
||||||
|
};
|
||||||
|
function resetVdesHistoryView() {
|
||||||
|
if (vdesMessagesEl) vdesMessagesEl.innerHTML = "";
|
||||||
|
vdesMessageHistory = [];
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
}
|
||||||
|
function renderVdesHistory() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
if (!vdesMessagesEl) {
|
||||||
updateVdesSummary();
|
updateVdesSummary();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
function addVdesMessage(msg) {
|
const fragment = document.createDocumentFragment();
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
for (const message of vdesMessageHistory) {
|
||||||
msg._tsMs = tsMs;
|
fragment.appendChild(renderVdesRow(message));
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
}
|
||||||
|
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 {
|
||||||
|
...msg,
|
||||||
|
rig_id: msg.rig_id || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerVdesBatch(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",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
vdesMessageHistory.unshift(msg);
|
|
||||||
pruneVdesMessageHistory();
|
|
||||||
scheduleVdesBarUpdate();
|
|
||||||
scheduleVdesHistoryRender();
|
|
||||||
}
|
|
||||||
function normalizeServerVdesMessage(msg) {
|
|
||||||
return {
|
|
||||||
...msg,
|
|
||||||
rig_id: msg.rig_id || null
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function onServerVdesBatch(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 && vdesWindow.vdesMapAddPoint) {
|
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
|
||||||
}
|
|
||||||
normalized.push(next);
|
|
||||||
}
|
|
||||||
normalized.reverse();
|
|
||||||
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
|
||||||
pruneVdesMessageHistory();
|
|
||||||
scheduleVdesBarUpdate();
|
|
||||||
scheduleVdesHistoryRender();
|
|
||||||
}
|
|
||||||
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
||||||
try {
|
|
||||||
await vdesWindow.postPath?.("/clear_vdes_decode");
|
|
||||||
resetVdesHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("VDES history clear failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
if (vdesFilterInput) {
|
|
||||||
vdesFilterInput.addEventListener("input", () => {
|
|
||||||
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
|
||||||
renderVdesHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
function onServerVdes(msg) {
|
|
||||||
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
|
||||||
const next = normalizeServerVdesMessage(msg);
|
|
||||||
addVdesMessage(next);
|
|
||||||
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||||
vdesWindow.vdesMapAddPoint(next);
|
vdesWindow.vdesMapAddPoint(next);
|
||||||
}
|
}
|
||||||
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
function pruneVdesHistoryView() {
|
normalized.reverse();
|
||||||
pruneVdesMessageHistory();
|
vdesMessageHistory = normalized.concat(vdesMessageHistory);
|
||||||
updateVdesBar();
|
pruneVdesMessageHistory();
|
||||||
|
scheduleVdesBarUpdate();
|
||||||
|
scheduleVdesHistoryRender();
|
||||||
|
}
|
||||||
|
document.getElementById("settings-clear-vdes-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await vdesWindow.trxUi.confirm({ title: "Clear VDES history?", message: "All stored VDES decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await vdesWindow.postPath?.("/clear_vdes_decode");
|
||||||
|
resetVdesHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("VDES history clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
if (vdesFilterInput) {
|
||||||
|
vdesFilterInput.addEventListener("input", () => {
|
||||||
|
vdesFilterText = vdesFilterInput.value.trim().toUpperCase();
|
||||||
renderVdesHistory();
|
renderVdesHistory();
|
||||||
}
|
|
||||||
updateVdesSummary();
|
|
||||||
window.trxPluginRuntime.registerDecoder({
|
|
||||||
id: "vdes",
|
|
||||||
onMessage: onServerVdes,
|
|
||||||
onBatch: onServerVdesBatch,
|
|
||||||
restore: onServerVdesBatch,
|
|
||||||
reset: resetVdesHistoryView,
|
|
||||||
prune: pruneVdesHistoryView
|
|
||||||
});
|
});
|
||||||
})();
|
}
|
||||||
|
function onServerVdes(msg) {
|
||||||
|
if (vdesStatus) vdesStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerVdesMessage(msg);
|
||||||
|
addVdesMessage(next);
|
||||||
|
if (next.lat != null && next.lon != null && vdesWindow.vdesMapAddPoint) {
|
||||||
|
vdesWindow.vdesMapAddPoint(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function pruneVdesHistoryView() {
|
||||||
|
pruneVdesMessageHistory();
|
||||||
|
updateVdesBar();
|
||||||
|
renderVdesHistory();
|
||||||
|
}
|
||||||
|
updateVdesSummary();
|
||||||
|
window.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "vdes",
|
||||||
|
onMessage: onServerVdes,
|
||||||
|
onBatch: onServerVdesBatch,
|
||||||
|
restore: onServerVdesBatch,
|
||||||
|
reset: resetVdesHistoryView,
|
||||||
|
prune: pruneVdesHistoryView
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,335 +1,332 @@
|
|||||||
"use strict";
|
// src/plugins/wefax.ts
|
||||||
(() => {
|
var wefaxWindow = window;
|
||||||
// src/plugins/wefax.ts
|
var wefaxDom = {
|
||||||
var wefaxWindow = window;
|
status: document.getElementById("wefax-status"),
|
||||||
var wefaxDom = {
|
liveView: document.getElementById("wefax-live-view"),
|
||||||
status: document.getElementById("wefax-status"),
|
historyView: document.getElementById("wefax-history-view"),
|
||||||
liveView: document.getElementById("wefax-live-view"),
|
liveContainer: document.getElementById("wefax-live-container"),
|
||||||
historyView: document.getElementById("wefax-history-view"),
|
liveInfo: document.getElementById("wefax-live-info"),
|
||||||
liveContainer: document.getElementById("wefax-live-container"),
|
liveCanvas: document.getElementById("wefax-live-canvas"),
|
||||||
liveInfo: document.getElementById("wefax-live-info"),
|
liveLatest: document.getElementById("wefax-live-latest"),
|
||||||
liveCanvas: document.getElementById("wefax-live-canvas"),
|
historyList: document.getElementById("wefax-history-list"),
|
||||||
liveLatest: document.getElementById("wefax-live-latest"),
|
historyCount: document.getElementById("wefax-history-count"),
|
||||||
historyList: document.getElementById("wefax-history-list"),
|
filterInput: document.getElementById("wefax-filter"),
|
||||||
historyCount: document.getElementById("wefax-history-count"),
|
sortSelect: document.getElementById("wefax-sort"),
|
||||||
filterInput: document.getElementById("wefax-filter"),
|
toggleBtn: document.getElementById("wefax-decode-toggle-btn"),
|
||||||
sortSelect: document.getElementById("wefax-sort"),
|
clearBtn: document.getElementById("wefax-clear-btn"),
|
||||||
toggleBtn: document.getElementById("wefax-decode-toggle-btn"),
|
viewLiveBtn: document.getElementById("wefax-view-live"),
|
||||||
clearBtn: document.getElementById("wefax-clear-btn"),
|
viewHistoryBtn: document.getElementById("wefax-view-history")
|
||||||
viewLiveBtn: document.getElementById("wefax-view-live"),
|
};
|
||||||
viewHistoryBtn: document.getElementById("wefax-view-history")
|
var wefaxImageHistory = [];
|
||||||
};
|
var WEFAX_MAX_IMAGES = 100;
|
||||||
var wefaxImageHistory = [];
|
var wefaxLiveCtx = null;
|
||||||
var WEFAX_MAX_IMAGES = 100;
|
var wefaxLiveLineCount = 0;
|
||||||
var wefaxLiveCtx = null;
|
var wefaxLivePixelsPerLine = 1809;
|
||||||
var wefaxLiveLineCount = 0;
|
var wefaxActiveView = "live";
|
||||||
var wefaxLivePixelsPerLine = 1809;
|
var wefaxFilterText = "";
|
||||||
var wefaxActiveView = "live";
|
function currentWefaxHistoryRetentionMs() {
|
||||||
var wefaxFilterText = "";
|
return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
||||||
function currentWefaxHistoryRetentionMs() {
|
}
|
||||||
return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3;
|
function pruneWefaxHistory() {
|
||||||
}
|
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
||||||
function pruneWefaxHistory() {
|
wefaxImageHistory = wefaxImageHistory.filter(function(m) {
|
||||||
const cutoff = Date.now() - currentWefaxHistoryRetentionMs();
|
return (m._tsMs || 0) > cutoff;
|
||||||
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 wefaxWindow.trxScheduleUiFrameJob === "function") {
|
|
||||||
wefaxWindow.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 escapeHtml(s) {
|
||||||
|
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||||
|
}
|
||||||
|
function scheduleWefaxUi(key, job) {
|
||||||
|
if (typeof wefaxWindow.trxScheduleUiFrameJob === "function") {
|
||||||
|
wefaxWindow.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");
|
||||||
});
|
});
|
||||||
function resetLiveCanvas(pixelsPerLine) {
|
if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active");
|
||||||
const canvas = wefaxDom.liveCanvas;
|
if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active");
|
||||||
if (!canvas) return;
|
if (view === "history") renderWefaxHistoryTable();
|
||||||
wefaxLivePixelsPerLine = pixelsPerLine;
|
}
|
||||||
wefaxLiveLineCount = 0;
|
if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() {
|
||||||
canvas.width = pixelsPerLine;
|
switchWefaxView("live");
|
||||||
canvas.height = 800;
|
});
|
||||||
|
if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() {
|
||||||
|
switchWefaxView("history");
|
||||||
|
});
|
||||||
|
function resetLiveCanvas(pixelsPerLine) {
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (!canvas) return;
|
||||||
|
wefaxLivePixelsPerLine = pixelsPerLine;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
canvas.width = pixelsPerLine;
|
||||||
|
canvas.height = 800;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.fillStyle = "#000";
|
||||||
|
wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "";
|
||||||
|
}
|
||||||
|
function paintLine(lineBytes) {
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (!wefaxLiveCtx || !canvas) return;
|
||||||
|
const y = wefaxLiveLineCount;
|
||||||
|
if (y >= canvas.height) {
|
||||||
|
const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
canvas.height *= 2;
|
||||||
wefaxLiveCtx = canvas.getContext("2d");
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
if (!wefaxLiveCtx) return;
|
if (!wefaxLiveCtx) return;
|
||||||
wefaxLiveCtx.fillStyle = "#000";
|
wefaxLiveCtx.putImageData(old, 0, 0);
|
||||||
wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "";
|
|
||||||
}
|
}
|
||||||
function paintLine(lineBytes) {
|
const w = wefaxLivePixelsPerLine;
|
||||||
const canvas = wefaxDom.liveCanvas;
|
const imgData = wefaxLiveCtx.createImageData(w, 1);
|
||||||
if (!wefaxLiveCtx || !canvas) return;
|
const d = imgData.data;
|
||||||
const y = wefaxLiveLineCount;
|
for (let x = 0; x < w; x++) {
|
||||||
if (y >= canvas.height) {
|
const v = lineBytes[x] ?? 0;
|
||||||
const old = wefaxLiveCtx.getImageData(0, 0, canvas.width, canvas.height);
|
const i = x * 4;
|
||||||
canvas.height *= 2;
|
d[i] = v;
|
||||||
wefaxLiveCtx = canvas.getContext("2d");
|
d[i + 1] = v;
|
||||||
if (!wefaxLiveCtx) return;
|
d[i + 2] = v;
|
||||||
wefaxLiveCtx.putImageData(old, 0, 0);
|
d[i + 3] = 255;
|
||||||
}
|
|
||||||
const w = wefaxLivePixelsPerLine;
|
|
||||||
const imgData = wefaxLiveCtx.createImageData(w, 1);
|
|
||||||
const d = imgData.data;
|
|
||||||
for (let x = 0; x < w; x++) {
|
|
||||||
const v = lineBytes[x] ?? 0;
|
|
||||||
const 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() {
|
wefaxLiveCtx.putImageData(imgData, 0, y);
|
||||||
if (!wefaxDom.liveLatest) return;
|
wefaxLiveLineCount++;
|
||||||
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>';
|
function renderWefaxLatestCard() {
|
||||||
return;
|
if (!wefaxDom.liveLatest) return;
|
||||||
}
|
if (wefaxImageHistory.length === 0) {
|
||||||
const img = wefaxImageHistory[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>';
|
||||||
if (!img) return;
|
return;
|
||||||
const ts = img._ts || "--";
|
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
|
||||||
const meta = [
|
|
||||||
`${String(img.ioc ?? "--")} IOC`,
|
|
||||||
`${String(img.lpm ?? "--")} LPM`,
|
|
||||||
`${String(img.line_count ?? 0)} lines`,
|
|
||||||
`${date} ${ts}`
|
|
||||||
].join(" · ");
|
|
||||||
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
|
||||||
let 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() {
|
const img = wefaxImageHistory[0];
|
||||||
let items = wefaxImageHistory;
|
if (!img) return;
|
||||||
if (wefaxFilterText) {
|
const ts = img._ts || "--";
|
||||||
items = items.filter(function(i) {
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString() : "";
|
||||||
const haystack = [
|
const meta = [
|
||||||
String(i.ioc || ""),
|
`${String(img.ioc ?? "--")} IOC`,
|
||||||
String(i.lpm || ""),
|
`${String(img.lpm ?? "--")} LPM`,
|
||||||
String(i.line_count || "")
|
`${String(img.line_count ?? 0)} lines`,
|
||||||
].join(" ").toUpperCase();
|
`${date} ${ts}`
|
||||||
return haystack.indexOf(wefaxFilterText) >= 0;
|
].join(" · ");
|
||||||
});
|
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
}
|
let html = '<div class="sat-latest-card">';
|
||||||
const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest";
|
html += '<div class="sat-latest-title">Latest decoded image</div>';
|
||||||
if (sortVal === "oldest") items = items.slice().reverse();
|
html += '<div class="sat-latest-meta">' + escapeHtml(meta) + "</div>";
|
||||||
return items;
|
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>';
|
||||||
}
|
}
|
||||||
function renderWefaxHistoryRow(img) {
|
html += "</div>";
|
||||||
const row = document.createElement("div");
|
wefaxDom.liveLatest.innerHTML = html;
|
||||||
row.className = "sat-history-row";
|
}
|
||||||
const ts = img._ts || "--";
|
function getWefaxFilteredHistory() {
|
||||||
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
let items = wefaxImageHistory;
|
||||||
const ioc = img.ioc || "--";
|
if (wefaxFilterText) {
|
||||||
const lpm = img.lpm || "--";
|
items = items.filter(function(i) {
|
||||||
const lines = img.line_count || 0;
|
const haystack = [
|
||||||
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
String(i.ioc || ""),
|
||||||
const link = imgSrc ? '<a href="' + imgSrc + '" target="_blank" style="color:var(--accent);">View</a>' : "--";
|
String(i.lpm || ""),
|
||||||
row.innerHTML = [
|
String(i.line_count || "")
|
||||||
"<span>" + escapeHtml(date + " " + ts) + "</span>",
|
].join(" ").toUpperCase();
|
||||||
"<span>" + escapeHtml(String(ioc)) + "</span>",
|
return haystack.indexOf(wefaxFilterText) >= 0;
|
||||||
"<span>" + escapeHtml(String(lpm)) + "</span>",
|
});
|
||||||
`<span>${String(lines)}</span>`,
|
|
||||||
"<span>" + link + "</span>"
|
|
||||||
].join("");
|
|
||||||
return row;
|
|
||||||
}
|
}
|
||||||
function renderWefaxHistoryTable() {
|
const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest";
|
||||||
if (!wefaxDom.historyList) return;
|
if (sortVal === "oldest") items = items.slice().reverse();
|
||||||
pruneWefaxHistory();
|
return items;
|
||||||
const items = getWefaxFilteredHistory();
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
function renderWefaxHistoryRow(img) {
|
||||||
for (const item of items) {
|
const row = document.createElement("div");
|
||||||
fragment.appendChild(renderWefaxHistoryRow(item));
|
row.className = "sat-history-row";
|
||||||
}
|
const ts = img._ts || "--";
|
||||||
wefaxDom.historyList.replaceChildren(fragment);
|
const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : "";
|
||||||
if (wefaxDom.historyCount) {
|
const ioc = img.ioc || "--";
|
||||||
const total = wefaxImageHistory.length;
|
const lpm = img.lpm || "--";
|
||||||
const shown = items.length;
|
const lines = img.line_count || 0;
|
||||||
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null;
|
||||||
|
const 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>${String(lines)}</span>`,
|
||||||
|
"<span>" + link + "</span>"
|
||||||
|
].join("");
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWefaxHistoryTable() {
|
||||||
|
if (!wefaxDom.historyList) return;
|
||||||
|
pruneWefaxHistory();
|
||||||
|
const items = getWefaxFilteredHistory();
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (const item of items) {
|
||||||
|
fragment.appendChild(renderWefaxHistoryRow(item));
|
||||||
|
}
|
||||||
|
wefaxDom.historyList.replaceChildren(fragment);
|
||||||
|
if (wefaxDom.historyCount) {
|
||||||
|
const total = wefaxImageHistory.length;
|
||||||
|
const shown = items.length;
|
||||||
|
wefaxDom.historyCount.textContent = total === 0 ? "No images yet" : shown === total ? `${String(total)} image${total === 1 ? "" : "s"}` : `${String(shown)} of ${String(total)} images`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function addWefaxImage(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"
|
||||||
|
});
|
||||||
|
const canvas = wefaxDom.liveCanvas;
|
||||||
|
if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) {
|
||||||
|
const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount);
|
||||||
|
canvas.height = wefaxLiveLineCount;
|
||||||
|
wefaxLiveCtx = canvas.getContext("2d");
|
||||||
|
if (!wefaxLiveCtx) return;
|
||||||
|
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
||||||
|
try {
|
||||||
|
msg._dataUrl = canvas.toDataURL("image/png");
|
||||||
|
} catch {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function addWefaxImage(msg) {
|
wefaxImageHistory.unshift(msg);
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
if (wefaxImageHistory.length > WEFAX_MAX_IMAGES) {
|
||||||
msg._tsMs = tsMs;
|
wefaxImageHistory = wefaxImageHistory.slice(0, WEFAX_MAX_IMAGES);
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
}
|
||||||
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
|
if (wefaxActiveView === "history") {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerWefaxProgress(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 ?? 0) <= 1 || !wefaxLiveCtx) {
|
||||||
|
resetLiveCanvas(msg.pixels_per_line || 1809);
|
||||||
|
}
|
||||||
|
if (msg.line_data) {
|
||||||
|
const binary = atob(msg.line_data);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||||
|
paintLine(bytes);
|
||||||
|
}
|
||||||
|
if (wefaxDom.liveInfo) {
|
||||||
|
wefaxDom.liveInfo.textContent = `Line ${String(msg.line_count ?? 0)} · ${String(msg.ioc ?? "--")} IOC · ${String(msg.lpm ?? "--")} LPM`;
|
||||||
|
}
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = `Receiving — line ${String(msg.line_count ?? 0)}`;
|
||||||
|
wefaxDom.status.style.color = "var(--text-accent)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function onServerWefax(msg) {
|
||||||
|
addWefaxImage(msg);
|
||||||
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function restoreWefaxHistory(messages) {
|
||||||
|
if (!messages.length) return;
|
||||||
|
for (const message of messages) {
|
||||||
|
const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now();
|
||||||
|
message._tsMs = tsMs;
|
||||||
|
message._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
hour: "2-digit",
|
hour: "2-digit",
|
||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
const canvas = wefaxDom.liveCanvas;
|
|
||||||
if (wefaxLiveCtx && canvas && wefaxLiveLineCount > 0) {
|
|
||||||
const trimmed = wefaxLiveCtx.getImageData(0, 0, canvas.width, wefaxLiveLineCount);
|
|
||||||
canvas.height = wefaxLiveLineCount;
|
|
||||||
wefaxLiveCtx = canvas.getContext("2d");
|
|
||||||
if (!wefaxLiveCtx) return;
|
|
||||||
wefaxLiveCtx.putImageData(trimmed, 0, 0);
|
|
||||||
try {
|
|
||||||
msg._dataUrl = canvas.toDataURL("image/png");
|
|
||||||
} catch {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
function onServerWefaxProgress(msg) {
|
wefaxImageHistory = messages.concat(wefaxImageHistory);
|
||||||
if (msg.state && !msg.line_data) {
|
pruneWefaxHistory();
|
||||||
if (wefaxDom.status) {
|
scheduleWefaxUi("wefax-latest", renderWefaxLatestCard);
|
||||||
wefaxDom.status.textContent = msg.state;
|
if (wefaxActiveView === "history") {
|
||||||
wefaxDom.status.style.color = msg.state.indexOf("Idle") === 0 ? "" : "var(--text-accent)";
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if ((msg.line_count ?? 0) <= 1 || !wefaxLiveCtx) {
|
|
||||||
resetLiveCanvas(msg.pixels_per_line || 1809);
|
|
||||||
}
|
|
||||||
if (msg.line_data) {
|
|
||||||
const binary = atob(msg.line_data);
|
|
||||||
const bytes = new Uint8Array(binary.length);
|
|
||||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
||||||
paintLine(bytes);
|
|
||||||
}
|
|
||||||
if (wefaxDom.liveInfo) {
|
|
||||||
wefaxDom.liveInfo.textContent = `Line ${String(msg.line_count ?? 0)} · ${String(msg.ioc ?? "--")} IOC · ${String(msg.lpm ?? "--")} LPM`;
|
|
||||||
}
|
|
||||||
if (wefaxDom.status) {
|
|
||||||
wefaxDom.status.textContent = `Receiving — line ${String(msg.line_count ?? 0)}`;
|
|
||||||
wefaxDom.status.style.color = "var(--text-accent)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function onServerWefax(msg) {
|
|
||||||
addWefaxImage(msg);
|
|
||||||
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
|
||||||
if (wefaxDom.status) {
|
|
||||||
wefaxDom.status.textContent = `Complete — ${String(msg.line_count ?? 0)} lines`;
|
|
||||||
wefaxDom.status.style.color = "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function restoreWefaxHistory(messages) {
|
|
||||||
if (!messages.length) return;
|
|
||||||
for (const message of messages) {
|
|
||||||
const tsMs = Number.isFinite(message.ts_ms) ? Number(message.ts_ms) : Date.now();
|
|
||||||
message._tsMs = tsMs;
|
|
||||||
message._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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function pruneWefaxHistoryView() {
|
|
||||||
pruneWefaxHistory();
|
|
||||||
renderWefaxHistoryTable();
|
|
||||||
renderWefaxLatestCard();
|
|
||||||
}
|
|
||||||
function resetWefaxHistoryView() {
|
|
||||||
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) {
|
|
||||||
const filterInput = wefaxDom.filterInput;
|
|
||||||
wefaxDom.filterInput.addEventListener("input", function() {
|
|
||||||
wefaxFilterText = filterInput.value.trim().toUpperCase();
|
|
||||||
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (wefaxDom.sortSelect) {
|
|
||||||
wefaxDom.sortSelect.addEventListener("change", function() {
|
|
||||||
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
wefaxWindow.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) {
|
|
||||||
const toggleButton = wefaxDom.toggleBtn;
|
|
||||||
wefaxDom.toggleBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
|
|
||||||
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
|
|
||||||
}
|
|
||||||
await wefaxWindow.postPath?.("/toggle_wefax_decode");
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WEFAX toggle failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (wefaxDom.clearBtn) {
|
|
||||||
wefaxDom.clearBtn.addEventListener("click", () => {
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
await wefaxWindow.postPath?.("/clear_wefax_decode");
|
|
||||||
resetWefaxHistoryView();
|
|
||||||
} catch (e) {
|
|
||||||
console.error("WEFAX clear failed", e);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
function pruneWefaxHistoryView() {
|
||||||
|
pruneWefaxHistory();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
renderWefaxLatestCard();
|
renderWefaxLatestCard();
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
}
|
||||||
id: "wefax",
|
function resetWefaxHistoryView() {
|
||||||
onMessage: onServerWefax,
|
wefaxImageHistory = [];
|
||||||
restore: restoreWefaxHistory,
|
if (wefaxDom.historyList) wefaxDom.historyList.innerHTML = "";
|
||||||
prune: pruneWefaxHistoryView,
|
if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = "none";
|
||||||
reset: resetWefaxHistoryView
|
wefaxLiveCtx = null;
|
||||||
|
wefaxLiveLineCount = 0;
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
renderWefaxHistoryTable();
|
||||||
|
if (wefaxDom.status) {
|
||||||
|
wefaxDom.status.textContent = "Idle";
|
||||||
|
wefaxDom.status.style.color = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (wefaxDom.filterInput) {
|
||||||
|
const filterInput = wefaxDom.filterInput;
|
||||||
|
wefaxDom.filterInput.addEventListener("input", function() {
|
||||||
|
wefaxFilterText = filterInput.value.trim().toUpperCase();
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
});
|
});
|
||||||
wefaxWindow.trxPluginRuntime.registerDecoder({
|
}
|
||||||
id: "wefax_progress",
|
if (wefaxDom.sortSelect) {
|
||||||
onMessage: onServerWefaxProgress
|
wefaxDom.sortSelect.addEventListener("change", function() {
|
||||||
|
scheduleWefaxUi("wefax-history", renderWefaxHistoryTable);
|
||||||
});
|
});
|
||||||
})();
|
}
|
||||||
|
wefaxWindow.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) {
|
||||||
|
const toggleButton = wefaxDom.toggleBtn;
|
||||||
|
wefaxDom.toggleBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
if (wefaxWindow.takeSchedulerControlForDecoderDisable) {
|
||||||
|
await wefaxWindow.takeSchedulerControlForDecoderDisable(toggleButton);
|
||||||
|
}
|
||||||
|
await wefaxWindow.postPath?.("/toggle_wefax_decode");
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX toggle failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (wefaxDom.clearBtn) {
|
||||||
|
wefaxDom.clearBtn.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await wefaxWindow.postPath?.("/clear_wefax_decode");
|
||||||
|
resetWefaxHistoryView();
|
||||||
|
} catch (e) {
|
||||||
|
console.error("WEFAX clear failed", e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
renderWefaxLatestCard();
|
||||||
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wefax",
|
||||||
|
onMessage: onServerWefax,
|
||||||
|
restore: restoreWefaxHistory,
|
||||||
|
prune: pruneWefaxHistoryView,
|
||||||
|
reset: resetWefaxHistoryView
|
||||||
|
});
|
||||||
|
wefaxWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wefax_progress",
|
||||||
|
onMessage: onServerWefaxProgress
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,255 +1,105 @@
|
|||||||
"use strict";
|
// src/plugins/wspr.ts
|
||||||
(() => {
|
var wsprWindow = window;
|
||||||
// src/plugins/wspr.ts
|
var wsprStatus = document.getElementById("wspr-status");
|
||||||
var wsprWindow = window;
|
var wsprPeriodEl = document.getElementById("wspr-period");
|
||||||
var wsprStatus = document.getElementById("wspr-status");
|
var wsprMessagesEl = document.getElementById("wspr-messages");
|
||||||
var wsprPeriodEl = document.getElementById("wspr-period");
|
var wsprFilterInput = document.getElementById("wspr-filter");
|
||||||
var wsprMessagesEl = document.getElementById("wspr-messages");
|
var WSPR_PERIOD_SECONDS = 120;
|
||||||
var wsprFilterInput = document.getElementById("wspr-filter");
|
var wsprFilterText = "";
|
||||||
var WSPR_PERIOD_SECONDS = 120;
|
var wsprMessageHistory = [];
|
||||||
var wsprFilterText = "";
|
function finiteNumber(value) {
|
||||||
var wsprMessageHistory = [];
|
const number = typeof value === "number" ? value : Number(value);
|
||||||
function finiteNumber(value) {
|
return Number.isFinite(number) ? number : null;
|
||||||
const number = typeof value === "number" ? value : Number(value);
|
}
|
||||||
return Number.isFinite(number) ? number : null;
|
function currentWsprHistoryRetentionMs() {
|
||||||
}
|
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
function currentWsprHistoryRetentionMs() {
|
}
|
||||||
return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
function pruneWsprMessageHistory() {
|
||||||
}
|
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
||||||
function pruneWsprMessageHistory() {
|
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
|
||||||
const cutoffMs = Date.now() - currentWsprHistoryRetentionMs();
|
}
|
||||||
wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs);
|
function scheduleWsprHistoryRender() {
|
||||||
}
|
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
||||||
function scheduleWsprHistoryRender() {
|
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
||||||
if (typeof wsprWindow.trxScheduleUiFrameJob === "function") {
|
|
||||||
wsprWindow.trxScheduleUiFrameJob("wspr-history", () => {
|
|
||||||
renderWsprHistory();
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
renderWsprHistory();
|
|
||||||
}
|
|
||||||
function fmtWsprTime(tsMs) {
|
|
||||||
if (!tsMs) return "--:--:--";
|
|
||||||
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
||||||
}
|
|
||||||
function updateWsprPeriodTimer() {
|
|
||||||
if (!wsprPeriodEl) return;
|
|
||||||
const nowSec = Math.floor(Date.now() / 1e3);
|
|
||||||
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
|
||||||
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
|
||||||
const ss = String(remaining % 60).padStart(2, "0");
|
|
||||||
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
|
||||||
}
|
|
||||||
updateWsprPeriodTimer();
|
|
||||||
setInterval(updateWsprPeriodTimer, 500);
|
|
||||||
function renderWsprRow(msg) {
|
|
||||||
const row = document.createElement("div");
|
|
||||||
row.className = "ft8-row";
|
|
||||||
row.dataset.decoder = "wspr";
|
|
||||||
const snr = finiteNumber(msg.snr_db);
|
|
||||||
const delta = finiteNumber(msg.dt_s);
|
|
||||||
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
|
||||||
const offsetHz = finiteNumber(msg.freq_hz);
|
|
||||||
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
|
|
||||||
const freq = rfHz?.toFixed(0) ?? "--";
|
|
||||||
const message = msg.message ?? "";
|
|
||||||
row.dataset.message = message.toUpperCase();
|
|
||||||
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
|
||||||
applyWsprFilterToRow(row);
|
|
||||||
return row;
|
|
||||||
}
|
|
||||||
function renderWsprHistory() {
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
if (!wsprMessagesEl) return;
|
|
||||||
const fragment = document.createDocumentFragment();
|
|
||||||
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
|
||||||
const message = wsprMessageHistory[i];
|
|
||||||
if (message) fragment.appendChild(renderWsprRow(message));
|
|
||||||
}
|
|
||||||
wsprMessagesEl.replaceChildren(fragment);
|
|
||||||
}
|
|
||||||
function addWsprMessage(msg) {
|
|
||||||
msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
|
||||||
wsprMessageHistory.unshift(msg);
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
scheduleWsprHistoryRender();
|
|
||||||
}
|
|
||||||
function normalizeServerWsprMessage(msg) {
|
|
||||||
const raw = msg.message ?? "";
|
|
||||||
const grids = extractAllGrids(raw);
|
|
||||||
const station = extractLikelyCallsign(raw);
|
|
||||||
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
|
||||||
const offsetHz = finiteNumber(msg.freq_hz);
|
|
||||||
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
|
|
||||||
return {
|
|
||||||
raw,
|
|
||||||
grids,
|
|
||||||
station,
|
|
||||||
rfHz,
|
|
||||||
history: {
|
|
||||||
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
|
||||||
ts_ms: msg.ts_ms,
|
|
||||||
snr_db: msg.snr_db,
|
|
||||||
dt_s: msg.dt_s,
|
|
||||||
freq_hz: msg.freq_hz,
|
|
||||||
message: raw
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
function onServerWsprBatch(messages) {
|
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
|
||||||
const normalized = [];
|
|
||||||
for (const msg of messages) {
|
|
||||||
const next = normalizeServerWsprMessage(msg);
|
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
|
||||||
...msg,
|
|
||||||
...next.rfHz === null ? {} : { 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();
|
|
||||||
}
|
|
||||||
function pruneWsprHistoryView() {
|
|
||||||
pruneWsprMessageHistory();
|
|
||||||
renderWsprHistory();
|
|
||||||
}
|
|
||||||
function escapeWsprHtml(input) {
|
|
||||||
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
||||||
}
|
|
||||||
function renderWsprMessage(message) {
|
|
||||||
let out = "";
|
|
||||||
let i = 0;
|
|
||||||
while (i < message.length) {
|
|
||||||
const ch = message[i];
|
|
||||||
if (isAlphaNum(ch)) {
|
|
||||||
let j = i + 1;
|
|
||||||
while (j < message.length && isAlphaNum(message[j])) j++;
|
|
||||||
const token = message.slice(i, j);
|
|
||||||
const grid = token.toUpperCase();
|
|
||||||
if (isMaidenheadGridToken(grid)) {
|
|
||||||
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
|
||||||
} else {
|
|
||||||
out += escapeWsprHtml(token);
|
|
||||||
}
|
|
||||||
i = j;
|
|
||||||
} else {
|
|
||||||
out += escapeWsprHtml(ch ?? "");
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function extractAllGrids(message) {
|
|
||||||
const out = [];
|
|
||||||
const seen = /* @__PURE__ */ new Set();
|
|
||||||
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
|
||||||
for (const token of parts) {
|
|
||||||
if (!token) continue;
|
|
||||||
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
|
||||||
seen.add(token);
|
|
||||||
out.push(token);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
function extractLikelyCallsign(message) {
|
|
||||||
const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
|
|
||||||
for (const token of parts) {
|
|
||||||
if (!token) continue;
|
|
||||||
if (token.length < 3 || token.length > 12) continue;
|
|
||||||
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
|
||||||
if (isMaidenheadGridToken(token)) continue;
|
|
||||||
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function isFtxFarewellToken(token) {
|
|
||||||
const normalized = token.trim().toUpperCase();
|
|
||||||
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
|
||||||
}
|
|
||||||
function isMaidenheadGridToken(token) {
|
|
||||||
const normalized = token.trim().toUpperCase();
|
|
||||||
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
|
||||||
}
|
|
||||||
function isAlphaNum(ch) {
|
|
||||||
return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
|
|
||||||
}
|
|
||||||
function activateWsprHistoryLocator(target) {
|
|
||||||
if (!(target instanceof Element)) return false;
|
|
||||||
const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
|
|
||||||
if (!locatorEl) return false;
|
|
||||||
const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
|
|
||||||
if (!grid) return false;
|
|
||||||
if (typeof wsprWindow.navigateToMapLocator === "function") {
|
|
||||||
wsprWindow.navigateToMapLocator(grid, "wspr");
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
function applyWsprFilterToRow(row) {
|
|
||||||
if (!wsprFilterText) {
|
|
||||||
row.style.display = "";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const message = row.dataset.message || "";
|
|
||||||
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
|
||||||
}
|
|
||||||
function resetWsprHistoryView() {
|
|
||||||
if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
|
|
||||||
wsprMessageHistory = [];
|
|
||||||
renderWsprHistory();
|
|
||||||
if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
|
|
||||||
}
|
|
||||||
if (wsprFilterInput) {
|
|
||||||
wsprFilterInput.addEventListener("input", () => {
|
|
||||||
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
|
||||||
renderWsprHistory();
|
renderWsprHistory();
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (wsprMessagesEl) {
|
renderWsprHistory();
|
||||||
wsprMessagesEl.addEventListener("click", (event) => {
|
}
|
||||||
if (!activateWsprHistoryLocator(event.target)) return;
|
function fmtWsprTime(tsMs) {
|
||||||
event.preventDefault();
|
if (!tsMs) return "--:--:--";
|
||||||
event.stopPropagation();
|
return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
});
|
}
|
||||||
wsprMessagesEl.addEventListener("keydown", (event) => {
|
function updateWsprPeriodTimer() {
|
||||||
if (event.key !== "Enter" && event.key !== " ") return;
|
if (!wsprPeriodEl) return;
|
||||||
if (!activateWsprHistoryLocator(event.target)) return;
|
const nowSec = Math.floor(Date.now() / 1e3);
|
||||||
event.preventDefault();
|
const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS;
|
||||||
event.stopPropagation();
|
const mm = String(Math.floor(remaining / 60)).padStart(2, "0");
|
||||||
});
|
const ss = String(remaining % 60).padStart(2, "0");
|
||||||
|
wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
updateWsprPeriodTimer();
|
||||||
|
setInterval(updateWsprPeriodTimer, 500);
|
||||||
|
function renderWsprRow(msg) {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "ft8-row";
|
||||||
|
row.dataset.decoder = "wspr";
|
||||||
|
const snr = finiteNumber(msg.snr_db);
|
||||||
|
const delta = finiteNumber(msg.dt_s);
|
||||||
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
||||||
|
const offsetHz = finiteNumber(msg.freq_hz);
|
||||||
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null;
|
||||||
|
const freq = rfHz?.toFixed(0) ?? "--";
|
||||||
|
const message = msg.message ?? "";
|
||||||
|
row.dataset.message = message.toUpperCase();
|
||||||
|
row.innerHTML = `<span class="ft8-time">${fmtWsprTime(msg.ts_ms)}</span><span class="ft8-snr">${snr?.toFixed(1) ?? "--"}</span><span class="ft8-dt">${delta?.toFixed(2) ?? "--"}</span><span class="ft8-freq">${freq}</span><span class="ft8-msg">${renderWsprMessage(message)}</span>`;
|
||||||
|
applyWsprFilterToRow(row);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
function renderWsprHistory() {
|
||||||
|
pruneWsprMessageHistory();
|
||||||
|
if (!wsprMessagesEl) return;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
for (let i = 0; i < wsprMessageHistory.length; i += 1) {
|
||||||
|
const message = wsprMessageHistory[i];
|
||||||
|
if (message) fragment.appendChild(renderWsprRow(message));
|
||||||
}
|
}
|
||||||
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
wsprMessagesEl.replaceChildren(fragment);
|
||||||
wsprDecodeToggleBtn?.addEventListener("click", () => {
|
}
|
||||||
void (async () => {
|
function addWsprMessage(msg) {
|
||||||
try {
|
msg._tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
wsprMessageHistory.unshift(msg);
|
||||||
await wsprWindow.postPath?.("/toggle_wspr_decode");
|
pruneWsprMessageHistory();
|
||||||
} catch (error) {
|
scheduleWsprHistoryRender();
|
||||||
console.error("WSPR toggle failed", error);
|
}
|
||||||
}
|
function normalizeServerWsprMessage(msg) {
|
||||||
})();
|
const raw = msg.message ?? "";
|
||||||
});
|
const grids = extractAllGrids(raw);
|
||||||
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
|
const station = extractLikelyCallsign(raw);
|
||||||
void (async () => {
|
const baseHz = finiteNumber(wsprWindow.ft8BaseHz);
|
||||||
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
const offsetHz = finiteNumber(msg.freq_hz);
|
||||||
try {
|
const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : offsetHz;
|
||||||
await wsprWindow.postPath?.("/clear_wspr_decode");
|
return {
|
||||||
resetWsprHistoryView();
|
raw,
|
||||||
} catch (error) {
|
grids,
|
||||||
console.error("WSPR history clear failed", error);
|
station,
|
||||||
}
|
rfHz,
|
||||||
})();
|
history: {
|
||||||
});
|
receiver: wsprWindow.getDecodeRigMeta ? wsprWindow.getDecodeRigMeta() : null,
|
||||||
function onServerWspr(msg) {
|
ts_ms: msg.ts_ms,
|
||||||
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
snr_db: msg.snr_db,
|
||||||
|
dt_s: msg.dt_s,
|
||||||
|
freq_hz: msg.freq_hz,
|
||||||
|
message: raw
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function onServerWsprBatch(messages) {
|
||||||
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
|
const normalized = [];
|
||||||
|
for (const msg of messages) {
|
||||||
const next = normalizeServerWsprMessage(msg);
|
const next = normalizeServerWsprMessage(msg);
|
||||||
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
@@ -257,14 +107,161 @@
|
|||||||
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
addWsprMessage(next.history);
|
next.history._tsMs = Number.isFinite(next.history.ts_ms) ? Number(next.history.ts_ms) : Date.now();
|
||||||
|
normalized.push(next.history);
|
||||||
}
|
}
|
||||||
wsprWindow.trxPluginRuntime.registerDecoder({
|
normalized.reverse();
|
||||||
id: "wspr",
|
wsprMessageHistory = normalized.concat(wsprMessageHistory);
|
||||||
onMessage: onServerWspr,
|
pruneWsprMessageHistory();
|
||||||
onBatch: onServerWsprBatch,
|
scheduleWsprHistoryRender();
|
||||||
restore: onServerWsprBatch,
|
}
|
||||||
prune: pruneWsprHistoryView,
|
function pruneWsprHistoryView() {
|
||||||
reset: resetWsprHistoryView
|
pruneWsprMessageHistory();
|
||||||
|
renderWsprHistory();
|
||||||
|
}
|
||||||
|
function escapeWsprHtml(input) {
|
||||||
|
return input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
}
|
||||||
|
function renderWsprMessage(message) {
|
||||||
|
let out = "";
|
||||||
|
let i = 0;
|
||||||
|
while (i < message.length) {
|
||||||
|
const ch = message[i];
|
||||||
|
if (isAlphaNum(ch)) {
|
||||||
|
let j = i + 1;
|
||||||
|
while (j < message.length && isAlphaNum(message[j])) j++;
|
||||||
|
const token = message.slice(i, j);
|
||||||
|
const grid = token.toUpperCase();
|
||||||
|
if (isMaidenheadGridToken(grid)) {
|
||||||
|
out += `<span class="ft8-locator" data-locator-grid="${grid}" role="button" tabindex="0" aria-label="Show locator ${grid} on map">${grid}</span>`;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(token);
|
||||||
|
}
|
||||||
|
i = j;
|
||||||
|
} else {
|
||||||
|
out += escapeWsprHtml(ch ?? "");
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function extractAllGrids(message) {
|
||||||
|
const out = [];
|
||||||
|
const seen = /* @__PURE__ */ new Set();
|
||||||
|
const parts = message.toUpperCase().split(/[^A-Z0-9]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (isMaidenheadGridToken(token) && !seen.has(token)) {
|
||||||
|
seen.add(token);
|
||||||
|
out.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
function extractLikelyCallsign(message) {
|
||||||
|
const parts = message.toUpperCase().split(/[^A-Z0-9/]+/);
|
||||||
|
for (const token of parts) {
|
||||||
|
if (!token) continue;
|
||||||
|
if (token.length < 3 || token.length > 12) continue;
|
||||||
|
if (token === "CQ" || token === "DE" || token === "QRZ" || token === "DX") continue;
|
||||||
|
if (isMaidenheadGridToken(token)) continue;
|
||||||
|
if (/^[A-Z0-9/]{1,5}\d[A-Z0-9/]{1,6}$/.test(token)) return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
function isFtxFarewellToken(token) {
|
||||||
|
const normalized = token.trim().toUpperCase();
|
||||||
|
return normalized === "RR73" || normalized === "73" || normalized === "RR";
|
||||||
|
}
|
||||||
|
function isMaidenheadGridToken(token) {
|
||||||
|
const normalized = token.trim().toUpperCase();
|
||||||
|
return /^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalized) && !isFtxFarewellToken(normalized);
|
||||||
|
}
|
||||||
|
function isAlphaNum(ch) {
|
||||||
|
return ch !== void 0 && /[A-Za-z0-9]/.test(ch);
|
||||||
|
}
|
||||||
|
function activateWsprHistoryLocator(target) {
|
||||||
|
if (!(target instanceof Element)) return false;
|
||||||
|
const locatorEl = target.closest(".ft8-locator[data-locator-grid]");
|
||||||
|
if (!locatorEl) return false;
|
||||||
|
const grid = (locatorEl.dataset.locatorGrid || "").toUpperCase();
|
||||||
|
if (!grid) return false;
|
||||||
|
if (typeof wsprWindow.navigateToMapLocator === "function") {
|
||||||
|
wsprWindow.navigateToMapLocator(grid, "wspr");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function applyWsprFilterToRow(row) {
|
||||||
|
if (!wsprFilterText) {
|
||||||
|
row.style.display = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const message = row.dataset.message || "";
|
||||||
|
row.style.display = message.includes(wsprFilterText) ? "" : "none";
|
||||||
|
}
|
||||||
|
function resetWsprHistoryView() {
|
||||||
|
if (wsprMessagesEl) wsprMessagesEl.innerHTML = "";
|
||||||
|
wsprMessageHistory = [];
|
||||||
|
renderWsprHistory();
|
||||||
|
if (wsprWindow.clearMapMarkersByType) wsprWindow.clearMapMarkersByType("wspr");
|
||||||
|
}
|
||||||
|
if (wsprFilterInput) {
|
||||||
|
wsprFilterInput.addEventListener("input", () => {
|
||||||
|
wsprFilterText = wsprFilterInput.value.trim().toUpperCase();
|
||||||
|
renderWsprHistory();
|
||||||
});
|
});
|
||||||
})();
|
}
|
||||||
|
if (wsprMessagesEl) {
|
||||||
|
wsprMessagesEl.addEventListener("click", (event) => {
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
wsprMessagesEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
if (!activateWsprHistoryLocator(event.target)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var wsprDecodeToggleBtn = document.getElementById("wspr-decode-toggle-btn");
|
||||||
|
wsprDecodeToggleBtn?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
await wsprWindow.takeSchedulerControlForDecoderDisable?.(wsprDecodeToggleBtn);
|
||||||
|
await wsprWindow.postPath?.("/toggle_wspr_decode");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WSPR toggle failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
document.getElementById("settings-clear-wspr-history")?.addEventListener("click", () => {
|
||||||
|
void (async () => {
|
||||||
|
if (!await wsprWindow.trxUi.confirm({ title: "Clear WSPR history?", message: "All stored WSPR decodes will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
|
try {
|
||||||
|
await wsprWindow.postPath?.("/clear_wspr_decode");
|
||||||
|
resetWsprHistoryView();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("WSPR history clear failed", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
function onServerWspr(msg) {
|
||||||
|
if (wsprStatus) wsprStatus.textContent = "Receiving";
|
||||||
|
const next = normalizeServerWsprMessage(msg);
|
||||||
|
if (next.grids.length > 0 && wsprWindow.mapAddLocator) {
|
||||||
|
wsprWindow.mapAddLocator(next.raw, next.grids, "wspr", next.station, {
|
||||||
|
...msg,
|
||||||
|
...next.rfHz === null ? {} : { freq_hz: next.rfHz }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
addWsprMessage(next.history);
|
||||||
|
}
|
||||||
|
wsprWindow.trxPluginRuntime.registerDecoder({
|
||||||
|
id: "wspr",
|
||||||
|
onMessage: onServerWspr,
|
||||||
|
onBatch: onServerWsprBatch,
|
||||||
|
restore: onServerWsprBatch,
|
||||||
|
prune: pruneWsprHistoryView,
|
||||||
|
reset: resetWsprHistoryView
|
||||||
|
});
|
||||||
|
|||||||
@@ -67,7 +67,10 @@ await build({
|
|||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
format: "iife",
|
format: "esm",
|
||||||
|
splitting: true,
|
||||||
|
entryNames: "[name]",
|
||||||
|
chunkNames: "chunk-[hash]",
|
||||||
platform: "browser",
|
platform: "browser",
|
||||||
target: "es2022",
|
target: "es2022",
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("APRS entry normalizes positioned packets without remote symbol assets", async () => {
|
test("APRS entry normalizes positioned packets without remote symbol assets", async () => {
|
||||||
const forwarded = [];
|
const forwarded = [];
|
||||||
@@ -26,7 +27,7 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as
|
|||||||
console,
|
console,
|
||||||
});
|
});
|
||||||
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
||||||
const source = await readFile(new URL("../../assets/web/generated/aprs.js", import.meta.url), "utf8");
|
const source = await bundleEntry(new URL("../src/plugins/aprs.ts", import.meta.url));
|
||||||
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import { build } from "esbuild";
|
||||||
|
|
||||||
|
export async function bundleEntry(entryUrl) {
|
||||||
|
const result = await build({
|
||||||
|
entryPoints: [entryUrl.pathname],
|
||||||
|
bundle: true,
|
||||||
|
format: "iife",
|
||||||
|
platform: "browser",
|
||||||
|
target: "es2022",
|
||||||
|
write: false,
|
||||||
|
});
|
||||||
|
const output = result.outputFiles[0];
|
||||||
|
if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);
|
||||||
|
return output.text;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => {
|
test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => {
|
||||||
let barRenderer;
|
let barRenderer;
|
||||||
@@ -30,7 +31,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async (
|
|||||||
console,
|
console,
|
||||||
});
|
});
|
||||||
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
||||||
const source = await readFile(new URL("../../assets/web/generated/ft2.js", import.meta.url), "utf8");
|
const source = await bundleEntry(new URL("../src/plugins/ft2.ts", import.meta.url));
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ test("FT8 entry installs shared parsing without relying on script globals", asyn
|
|||||||
console,
|
console,
|
||||||
});
|
});
|
||||||
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
||||||
const source = await readFile(new URL("../../assets/web/generated/ft8.js", import.meta.url), "utf8");
|
const source = await bundleEntry(new URL("../src/plugins/ft8.ts", import.meta.url));
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import assert from "node:assert/strict";
|
|||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import vm from "node:vm";
|
import vm from "node:vm";
|
||||||
|
import { bundleEntry } from "./bundle-entry.mjs";
|
||||||
|
|
||||||
test("HF APRS entry uses shared typed normalization and local symbols", async () => {
|
test("HF APRS entry uses shared typed normalization and local symbols", async () => {
|
||||||
const window = { trxUi: { confirm: async () => true } };
|
const window = { trxUi: { confirm: async () => true } };
|
||||||
@@ -22,7 +23,7 @@ test("HF APRS entry uses shared typed normalization and local symbols", async ()
|
|||||||
console,
|
console,
|
||||||
});
|
});
|
||||||
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
const runtime = await readFile(new URL("../../assets/web/generated/plugin-runtime.js", import.meta.url), "utf8");
|
||||||
const source = await readFile(new URL("../../assets/web/generated/hf-aprs.js", import.meta.url), "utf8");
|
const source = await bundleEntry(new URL("../src/plugins/hf-aprs.ts", import.meta.url));
|
||||||
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
assert.equal(source.includes("raw.githubusercontent.com"), false);
|
||||||
new vm.Script(runtime).runInContext(context);
|
new vm.Script(runtime).runInContext(context);
|
||||||
new vm.Script(source).runInContext(context);
|
new vm.Script(source).runInContext(context);
|
||||||
|
|||||||
Reference in New Issue
Block a user