diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js index ea21ccf1..3a85696f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ais.js @@ -1,334 +1,331 @@ -"use strict"; -(() => { - // src/plugins/ais.ts - var aisWindow = window; - var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); - var aisStatus = document.getElementById("ais-status"); - var aisMessagesEl = document.getElementById("ais-messages"); - var aisFilterInput = document.getElementById("ais-filter"); - var aisBarOverlay = document.getElementById("ais-bar-overlay"); - var aisChannelSummaryEl = document.getElementById("ais-channel-summary"); - var aisVesselCountEl = document.getElementById("ais-vessel-count"); - var aisLatestSeenEl = document.getElementById("ais-latest-seen"); - var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3; - var AIS_DEFAULT_A_HZ = 161975e3; - var AIS_CHANNEL_SPACING_HZ = 5e4; - var aisFilterText = ""; - var aisMessageHistory = []; - function currentAisHistoryRetentionMs() { - return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +// src/plugins/ais.ts +var aisWindow = window; +var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var aisStatus = document.getElementById("ais-status"); +var aisMessagesEl = document.getElementById("ais-messages"); +var aisFilterInput = document.getElementById("ais-filter"); +var aisBarOverlay = document.getElementById("ais-bar-overlay"); +var aisChannelSummaryEl = document.getElementById("ais-channel-summary"); +var aisVesselCountEl = document.getElementById("ais-vessel-count"); +var aisLatestSeenEl = document.getElementById("ais-latest-seen"); +var AIS_BAR_WINDOW_MS = 15 * 60 * 1e3; +var AIS_DEFAULT_A_HZ = 161975e3; +var AIS_CHANNEL_SPACING_HZ = 5e4; +var aisFilterText = ""; +var aisMessageHistory = []; +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() { - 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; - } - job(); - } - function scheduleAisHistoryRender() { - scheduleAisUi("ais-history", () => { - renderAisHistory(); - }); - } - function scheduleAisBarUpdate() { - scheduleAisUi("ais-bar", () => { - updateAisBar(); - }); - } - function formatAisMhz(freqHz) { - return `${(freqHz / 1e6).toFixed(3)} MHz`; - } - function currentAisChannelPlan() { - const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); - const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ; - const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ; - return { - aHz: safeAHz, - bHz: safeAHz + AIS_CHANNEL_SPACING_HZ - }; - } - function aisChannelInfo(channel) { - const plan = currentAisChannelPlan(); - const ch = (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 `${label}`; - } - 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 ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` : ""; - 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 = `
${ts}${nameHtml}${escapeAisHtml(channel.label)}${escapeAisHtml(aisTypeLabel(msg.message_type))}
MMSI ${escapeAisHtml(String(msg.mmsi))}` + (route ? `${escapeAisHtml(route)}` : "") + `${escapeAisHtml(channel.freqText)}
` + (motion ? `${escapeAisHtml(motion)}` : `No motion data`) + (distance ? `${escapeAisHtml(distance)}` : "") + (pos ? `${pos}` : "") + `${escapeAisHtml(aisAgeText(msg._tsMs))}
`; - 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 = `
AISLiveClearLast 15 minutes
`; - for (const msg of messages) { - const ts = msg._ts ? `${msg._ts}` : ""; - const pin = msg.lat != null && msg.lon != null ? `` : ""; - const name = `${aisDisplayNameHtml(msg)}`; - 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 += `
${ts}${pin}${name}: ${details}
`; - } - aisBarOverlay.innerHTML = html; - aisBarOverlay.style.display = "flex"; - } - aisWindow.updateAisBar = updateAisBar; - aisWindow.clearAisBar = function() { - resetAisHistoryView(); + job(); +} +function scheduleAisHistoryRender() { + scheduleAisUi("ais-history", () => { + renderAisHistory(); + }); +} +function scheduleAisBarUpdate() { + scheduleAisUi("ais-bar", () => { + updateAisBar(); + }); +} +function formatAisMhz(freqHz) { + return `${(freqHz / 1e6).toFixed(3)} MHz`; +} +function currentAisChannelPlan() { + const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); + const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ; + const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ; + return { + aHz: safeAHz, + bHz: safeAHz + AIS_CHANNEL_SPACING_HZ }; - function resetAisHistoryView() { - if (aisMessagesEl) aisMessagesEl.innerHTML = ""; - aisMessageHistory = []; - updateAisBar(); - renderAisHistory(); - aisWindow.clearMapMarkersByType?.("ais"); +} +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) + }; } - function renderAisHistory() { - pruneAisMessageHistory(); - if (!aisMessagesEl) { - updateAisSummary(); - return; + 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 `${label}`; +} +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)); - } - aisMessagesEl.replaceChildren(fragment); + } +} +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 ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` : ""; + 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 = `
${ts}${nameHtml}${escapeAisHtml(channel.label)}${escapeAisHtml(aisTypeLabel(msg.message_type))}
MMSI ${escapeAisHtml(String(msg.mmsi))}` + (route ? `${escapeAisHtml(route)}` : "") + `${escapeAisHtml(channel.freqText)}
` + (motion ? `${escapeAisHtml(motion)}` : `No motion data`) + (distance ? `${escapeAisHtml(distance)}` : "") + (pos ? `${pos}` : "") + `${escapeAisHtml(aisAgeText(msg._tsMs))}
`; + 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 = `
AISLiveClearLast 15 minutes
`; + for (const msg of messages) { + const ts = msg._ts ? `${msg._ts}` : ""; + const pin = msg.lat != null && msg.lon != null ? `` : ""; + const name = `${aisDisplayNameHtml(msg)}`; + 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 += `
${ts}${pin}${name}: ${details}
`; + } + 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(); + return; } - 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([], { + const fragment = document.createDocumentFragment(); + for (const message of aisMessageHistory) { + fragment.appendChild(renderAisRow(message)); + } + 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", minute: "2-digit", second: "2-digit" }); - aisMessageHistory.unshift(msg); - pruneAisMessageHistory(); - scheduleAisBarUpdate(); - scheduleAisHistoryRender(); - if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { - aisWindow.aisMapAddVessel(msg); + if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { + aisWindow.aisMapAddVessel(next); } + normalized.push(next); } - 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", - minute: "2-digit", - second: "2-digit" - }); - if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { - aisWindow.aisMapAddVessel(next); - } - normalized.push(next); + normalized.reverse(); + aisMessageHistory = normalized.concat(aisMessageHistory); + pruneAisMessageHistory(); + scheduleAisBarUpdate(); + scheduleAisHistoryRender(); +} +function pruneAisHistoryView() { + pruneAisMessageHistory(); + updateAisBar(); + 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); } - normalized.reverse(); - aisMessageHistory = normalized.concat(aisMessageHistory); - pruneAisMessageHistory(); - scheduleAisBarUpdate(); - scheduleAisHistoryRender(); - } - function pruneAisHistoryView() { - pruneAisMessageHistory(); - updateAisBar(); + })(); +}); +if (aisFilterInput) { + aisFilterInput.addEventListener("input", () => { + aisFilterText = aisFilterInput.value.trim().toUpperCase(); 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", () => { - aisFilterText = aisFilterInput.value.trim().toUpperCase(); - renderAisHistory(); - }); - } - function onServerAis(msg) { - if (aisStatus) aisStatus.textContent = "Receiving"; - addAisMessage(normalizeServerAisMessage(msg)); - } - updateAisSummary(); - window.trxPluginRuntime.registerDecoder({ - id: "ais", - onMessage: onServerAis, - onBatch: onServerAisBatch, - restore: onServerAisBatch, - reset: resetAisHistoryView, - prune: pruneAisHistoryView - }); -})(); +} +function onServerAis(msg) { + if (aisStatus) aisStatus.textContent = "Receiving"; + addAisMessage(normalizeServerAisMessage(msg)); +} +updateAisSummary(); +window.trxPluginRuntime.registerDecoder({ + id: "ais", + onMessage: onServerAis, + onBatch: onServerAisBatch, + restore: onServerAisBatch, + reset: resetAisHistoryView, + prune: pruneAisHistoryView +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index dbb05b92..f56e02b4 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -1,408 +1,314 @@ -"use strict"; -(() => { - // 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)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; - } - function renderAprsCharacter(character) { - const code = character.charCodeAt(0); - return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; - } - 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 `${symbol}`; - } - 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 - }; - } +import { + aprsAgeText, + aprsCategoryLabel, + aprsHexBytes, + aprsPacketCategory, + collapseAprsDuplicates, + normalizeAprsPacket, + renderAprsInfo, + renderLocalAprsSymbol +} from "./chunk-M2I6DH4X.js"; - // src/plugins/aprs.ts - var aprsWindow = window; - var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); - var showAprsHint = (message, durationMs) => { - aprsWindow.showHint?.(message, durationMs); - }; - var aprsStatus = document.getElementById("aprs-status"); - var aprsPacketsEl = document.getElementById("aprs-packets"); - var aprsFilterInput = document.getElementById("aprs-filter"); - var aprsBarOverlay = document.getElementById("aprs-bar-overlay"); - var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); - var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); - var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); - var aprsTotalCountEl = document.getElementById("aprs-total-count"); - var aprsVisibleCountEl = document.getElementById("aprs-visible-count"); - var aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); - var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3; - var aprsFilterText = ""; - var aprsPacketHistory = []; - var aprsBarDismissedAtMs = 0; - var aprsOnlyPos = false; - var aprsHideCrc = false; - var aprsCollapseDup = false; - var aprsTypeFilter = "all"; - function currentAprsHistoryRetentionMs() { - return typeof aprsWindow.getDecodeHistoryRetentionMs === "function" ? aprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +// src/plugins/aprs.ts +var aprsWindow = window; +var escapeAprsHtml = (input) => aprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var showAprsHint = (message, durationMs) => { + aprsWindow.showHint?.(message, durationMs); +}; +var aprsStatus = document.getElementById("aprs-status"); +var aprsPacketsEl = document.getElementById("aprs-packets"); +var aprsFilterInput = document.getElementById("aprs-filter"); +var aprsBarOverlay = document.getElementById("aprs-bar-overlay"); +var aprsOnlyPosBtn = document.getElementById("aprs-only-pos-btn"); +var aprsHideCrcBtn = document.getElementById("aprs-hide-crc-btn"); +var aprsCollapseDupBtn = document.getElementById("aprs-collapse-dup-btn"); +var aprsTotalCountEl = document.getElementById("aprs-total-count"); +var aprsVisibleCountEl = document.getElementById("aprs-visible-count"); +var aprsLatestSeenEl = document.getElementById("aprs-latest-seen"); +var APRS_BAR_WINDOW_MS = 15 * 60 * 1e3; +var aprsFilterText = ""; +var aprsPacketHistory = []; +var aprsBarDismissedAtMs = 0; +var aprsOnlyPos = false; +var aprsHideCrc = false; +var aprsCollapseDup = false; +var aprsTypeFilter = "all"; +function currentAprsHistoryRetentionMs() { + 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() { - const cutoffMs = Date.now() - currentAprsHistoryRetentionMs(); - aprsPacketHistory = aprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs); + job(); +} +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 (typeof aprsWindow.trxScheduleUiFrameJob === "function") { - aprsWindow.trxScheduleUiFrameJob(key, job); - return; + 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)}`; } - job(); } - 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`; - } - if (aprsVisibleCountEl) { - aprsVisibleCountEl.textContent = `${visible.length} shown`; - } - if (aprsLatestSeenEl) { - const latest = aprsPacketHistory[0]; - if (!latest) { - aprsLatestSeenEl.textContent = "No packets yet"; - } else { - aprsLatestSeenEl.textContent = `${latest.srcCall} ${aprsAgeText(latest._tsMs)}`; +} +function updateAprsChipState() { + document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => { + btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`); + }); + aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos); + aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc); + aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); +} +function renderAprsRow(pkt, isFresh) { + const row = document.createElement("div"); + row.className = "aprs-packet"; + if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); + if (isFresh) row.classList.add("aprs-packet-new"); + const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const age = aprsAgeText(pkt._tsMs); + const category = aprsPacketCategory(pkt); + const categoryLabel = aprsCategoryLabel(category); + const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; + const pathBadge = pkt.path ? `${escapeAprsHtml(pkt.path)}` : ""; + const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; + const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); + const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; + const distance = aprsDistanceText(pkt); + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; + row.innerHTML = `
${ts}` + symbolHtml + `${escapeAprsHtml(pkt.srcCall ?? "")}>${escapeAprsHtml(pkt.destCall || "")}${escapeAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeAprsHtml(age)}` + (distance ? `${escapeAprsHtml(distance)}` : "") + `${escapeAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeAprsHtml(pkt.srcCall || "--")}Destination${escapeAprsHtml(pkt.destCall || "--")}Type${escapeAprsHtml(pkt.type || "--")}Path${escapeAprsHtml(pkt.path || "--")}Age${escapeAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeAprsHtml(pkt.info || "--")}Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; + 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); } - } - } - function updateAprsChipState() { - document.querySelectorAll("[id^='aprs-type-']").forEach((btn) => { - btn.classList.toggle("active", btn.id === `aprs-type-${aprsTypeFilter}`); }); - aprsOnlyPosBtn?.classList.toggle("active", aprsOnlyPos); - aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc); - aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); - } - function renderAprsRow(pkt, isFresh) { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; - const distance = aprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - row.innerHTML = `
${ts}` + symbolHtml + `${escapeAprsHtml(pkt.srcCall ?? "")}>${escapeAprsHtml(pkt.destCall || "")}${escapeAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeAprsHtml(age)}` + (distance ? `${escapeAprsHtml(distance)}` : "") + `${escapeAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeAprsHtml(pkt.srcCall || "--")}Destination${escapeAprsHtml(pkt.destCall || "--")}Type${escapeAprsHtml(pkt.type || "--")}Path${escapeAprsHtml(pkt.path || "--")}Age${escapeAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeAprsHtml(pkt.info || "--")}Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; - 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); + }); + 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); } - })(); - }); - } - return row; + } catch { + showAprsHint("Copy failed", 1500); + } + })(); + }); } - function renderAprsHistory() { - pruneAprsPacketHistory(); - if (!aprsPacketsEl) { - updateAprsSummary(); - updateAprsChipState(); - return; - } - const visible = aprsVisiblePackets(); - const fragment = document.createDocumentFragment(); - for (const [index, packet] of visible.entries()) { - fragment.appendChild(renderAprsRow(packet, index === 0)); - } - aprsPacketsEl.replaceChildren(fragment); + return row; +} +function renderAprsHistory() { + pruneAprsPacketHistory(); + if (!aprsPacketsEl) { updateAprsSummary(); updateAprsChipState(); + return; } - function updateAprsBar() { - if (!aprsBarOverlay) return; - const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; - const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; - const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 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 = `
APRSLiveLast 15 minutesClear
`; - for (const pkt of frames) { - const ts = pkt._ts ? `${pkt._ts}` : ""; - const call = `${escapeAprsHtml(pkt.srcCall ?? "")}`; - const dest = escapeAprsHtml(pkt.destCall || ""); - const info = escapeAprsHtml(pkt.info || ""); - const pin = pkt.lat != null && pkt.lon != null ? `` : ""; - html += `
${ts}${pin}${call}>${dest}: ${info}
`; - } - aprsBarOverlay.innerHTML = html; - aprsBarOverlay.style.display = "flex"; + const visible = aprsVisiblePackets(); + const fragment = document.createDocumentFragment(); + for (const [index, packet] of visible.entries()) { + fragment.appendChild(renderAprsRow(packet, index === 0)); } - aprsWindow.updateAprsBar = updateAprsBar; - aprsWindow.clearAprsBar = function() { - resetAprsHistoryView(); - }; - aprsWindow.closeAprsBar = function() { - aprsBarDismissedAtMs = Date.now(); - if (aprsBarOverlay) { - aprsBarOverlay.style.display = "none"; - aprsBarOverlay.innerHTML = ""; - } - }; - function resetAprsHistoryView() { - if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; - aprsPacketHistory = []; - updateAprsBar(); - renderAprsHistory(); - aprsWindow.clearMapMarkersByType?.("aprs"); + aprsPacketsEl.replaceChildren(fragment); + updateAprsSummary(); + updateAprsChipState(); +} +function updateAprsBar() { + if (!aprsBarOverlay) return; + const isPkt = (document.getElementById("mode")?.value || "").toUpperCase() === "PKT"; + const cutoffMs = Date.now() - APRS_BAR_WINDOW_MS; + const okFrames = aprsPacketHistory.filter((p) => p.crcOk && (p._tsMs ?? 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; } - function pruneAprsHistoryView() { - pruneAprsPacketHistory(); - updateAprsBar(); - renderAprsHistory(); + let html = `
APRSLiveLast 15 minutesClear
`; + for (const pkt of frames) { + const ts = pkt._ts ? `${pkt._ts}` : ""; + const call = `${escapeAprsHtml(pkt.srcCall ?? "")}`; + const dest = escapeAprsHtml(pkt.destCall || ""); + const info = escapeAprsHtml(pkt.info || ""); + const pin = pkt.lat != null && pkt.lon != null ? `` : ""; + html += `
${ts}${pin}${call}>${dest}: ${info}
`; } - 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)); + aprsBarOverlay.innerHTML = html; + aprsBarOverlay.style.display = "flex"; +} +aprsWindow.updateAprsBar = updateAprsBar; +aprsWindow.clearAprsBar = function() { + resetAprsHistoryView(); +}; +aprsWindow.closeAprsBar = function() { + aprsBarDismissedAtMs = Date.now(); + if (aprsBarOverlay) { + aprsBarOverlay.style.display = "none"; + aprsBarOverlay.innerHTML = ""; } +}; +function resetAprsHistoryView() { + if (aprsPacketsEl) aprsPacketsEl.innerHTML = ""; + aprsPacketHistory = []; + updateAprsBar(); renderAprsHistory(); - window.trxPluginRuntime.registerDecoder({ - id: "aprs", - onMessage: onServerAprs, - onBatch: onServerAprsBatch, - restore: onServerAprsBatch, - reset: resetAprsHistoryView, - prune: pruneAprsHistoryView + aprsWindow.clearMapMarkersByType?.("aprs"); +} +function pruneAprsHistoryView() { + pruneAprsPacketHistory(); + updateAprsBar(); + renderAprsHistory(); +} +function addAprsPacket(pkt) { + const tsMs = Number.isFinite(pkt.ts_ms) ? Number(pkt.ts_ms) : Date.now(); + pkt._tsMs = tsMs; + pkt._ts = new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + aprsPacketHistory.unshift(pkt); + pruneAprsPacketHistory(); + if (pkt.lat != null && pkt.lon != null && 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 +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js index a425cb37..289fad86 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/background-decode.js @@ -1,365 +1,362 @@ -"use strict"; -(() => { - // src/plugins/background-decode.ts - var bgdWindow = window; - (function() { - "use strict"; - function bgdSupportedIds() { - return (bgdWindow.decoderRegistry || []).filter(function(d) { - return d.background_decode; - }).map(function(d) { - return d.id; +// src/plugins/background-decode.ts +var bgdWindow = window; +(function() { + "use strict"; + function bgdSupportedIds() { + return (bgdWindow.decoderRegistry || []).filter(function(d) { + return d.background_decode; + }).map(function(d) { + return d.id; + }); + } + let backgroundDecodeRole = null; + let currentRigId = null; + let currentConfig = null; + let bookmarkList = []; + let statusInterval = null; + let bgdDirty = false; + function initBackgroundDecode(rigId, role) { + backgroundDecodeRole = role; + currentRigId = rigId || null; + if (currentRigId) loadBackgroundDecode(); + startStatusPolling(); + } + function setBackgroundDecodeRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + if (!currentRigId) return; + loadBackgroundDecode(); + } + function apiGetConfig(rigId) { + return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) { + if (!r.ok) throw new Error(`HTTP ${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; - 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(); + 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 = '
' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "
"; + return; } - function setBackgroundDecodeRig(rigId) { - const nextRigId = rigId || null; - if (nextRigId === currentRigId) return; - currentRigId = nextRigId; - if (!currentRigId) return; - loadBackgroundDecode(); + 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 = '' + escHtml(bookmark.name) + '' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + ""; + 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) { - return fetch("/background-decode/" + encodeURIComponent(rigId)).then(function(r) { - if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); - return r.json(); + 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; }); } - 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(); + 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 ? '
' + escHtml(summary.join(" · ")) + "
" : ""; + html += '
'; + 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 += '
' + escHtml(name) + '
' + escHtml(parts.join(" · ")) + '
' + escHtml(prettyState(entry.state)) + "
"; + }); + html += "
"; + 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, """); + } + 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) { - return fetch("/background-decode/" + encodeURIComponent(rigId), { - method: "DELETE" - }).then(function(r) { - if (!r.ok) throw new Error(`HTTP ${String(r.status)}`); - return r.json(); + const enabledCb = document.getElementById("background-decode-enabled"); + if (enabledCb && !enabledCb._wired) { + enabledCb._wired = true; + enabledCb.addEventListener("change", function() { + markBgdDirty(); }); } - 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(); + 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(); }); } - 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; - }); - } - 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 = '
' + (all.length === 0 ? "No supported bookmarks available." : "No bookmarks match filter.") + "
"; - 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 = '' + escHtml(bookmark.name) + '' + escHtml(formatFreq(bookmark.freq_hz) + " " + bookmark.mode + " · " + decoders.join("/").toUpperCase()) + ""; - 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 ? '
' + escHtml(summary.join(" · ")) + "
" : ""; - html += '
'; - 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 += '
' + escHtml(name) + '
' + escHtml(parts.join(" · ")) + '
' + escHtml(prettyState(entry.state)) + "
"; - }); - html += "
"; - 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, """); - } - 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; - })(); + } + bgdWindow.initBackgroundDecode = initBackgroundDecode; + bgdWindow.wireBackgroundDecodeEvents = wireBackgroundDecodeEvents; + bgdWindow.setBackgroundDecodeRig = setBackgroundDecodeRig; })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js index d33c6ad0..6bcd316f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/bookmarks.js @@ -1,679 +1,676 @@ -"use strict"; -(() => { - // src/plugins/bookmarks.ts - var bridge = window; - function bmEl(id) { - const element = document.getElementById(id); - if (!element) throw new Error(`Missing bookmark element #${id}`); - return element; +// src/plugins/bookmarks.ts +var bridge = window; +function bmEl(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing bookmark element #${id}`); + return element; +} +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} +var bmScope = "general"; +function bmScopeParam(prefix, scope) { + const sep = prefix ? "&" : "?"; + return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope); +} +var bmList = []; +var bmOverlayList = []; +var bmOverlayRevision = 0; +var bmFilteredList = []; +var bmEditScope = null; +var bmCurrentPage = 1; +var BM_PAGE_SIZE = 25; +var bmSelected = /* @__PURE__ */ new Set(); +function bmFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz"; + return `${hz} Hz`; +} +function bmEsc(str) { + const d = document.createElement("div"); + d.appendChild(document.createTextNode(String(str))); + return d.innerHTML; +} +function bmCanControl() { + return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; +} +function bmSyncAccess() { + const canCtrl = bmCanControl(); + const addBtn = bmEl("bm-add-btn"); + const selectAllBtn = bmEl("bm-select-all-btn"); + if (addBtn) addBtn.style.display = canCtrl ? "" : "none"; + if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; +} +function bmListScope() { + const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null; + return rig || "general"; +} +async function bmFetchOverlay() { + const overlayScope = bmListScope(); + try { + const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope)); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + bmOverlayList = await resp.json(); + } catch (e) { + console.error("Failed to fetch overlay bookmarks:", e); + bmOverlayList = []; } - function errorMessage(error) { - return error instanceof Error ? error.message : String(error); + bmOverlayRevision++; + if (typeof bridge.syncBookmarkMapLocators === "function") { + bridge.syncBookmarkMapLocators(bmOverlayList); } - var bmScope = "general"; - function bmScopeParam(prefix, scope) { - const sep = prefix ? "&" : "?"; - return sep + "scope=" + encodeURIComponent(scope != null ? scope : bmScope); + if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); +} +async function bmFetch(categoryFilter) { + let url = "/bookmarks"; + let hasQuery = false; + if (categoryFilter && categoryFilter !== "") { + url += "?category=" + encodeURIComponent(categoryFilter); + hasQuery = true; } - var bmList = []; - var bmOverlayList = []; - var bmOverlayRevision = 0; - var bmFilteredList = []; - var bmEditScope = null; - var bmCurrentPage = 1; - var BM_PAGE_SIZE = 25; - var bmSelected = /* @__PURE__ */ new Set(); - function bmFmtFreq(hz) { - if (!Number.isFinite(hz) || hz <= 0) return "--"; - if (hz >= 1e9) return (hz / 1e9).toFixed(6).replace(/\.?0+$/, "") + " GHz"; - if (hz >= 1e6) return (hz / 1e6).toFixed(6).replace(/\.?0+$/, "") + " MHz"; - if (hz >= 1e3) return (hz / 1e3).toFixed(3).replace(/\.?0+$/, "") + " kHz"; - return `${hz} Hz`; + url += bmScopeParam(hasQuery); + const overlayPromise = bmFetchOverlay(); + try { + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + bmList = await resp.json(); + } catch (e) { + console.error("Failed to fetch bookmarks:", e); + bmList = []; } - function bmEsc(str) { - const d = document.createElement("div"); - d.appendChild(document.createTextNode(String(str))); - return d.innerHTML; - } - function bmCanControl() { - return typeof bridge.authEnabled !== "undefined" && !bridge.authEnabled || typeof bridge.authRole !== "undefined" && bridge.authRole === "control"; - } - function bmSyncAccess() { - const canCtrl = bmCanControl(); - const addBtn = bmEl("bm-add-btn"); - const selectAllBtn = bmEl("bm-select-all-btn"); - if (addBtn) addBtn.style.display = canCtrl ? "" : "none"; - if (selectAllBtn) selectAllBtn.style.display = canCtrl ? "" : "none"; - } - function bmListScope() { - const rig = typeof bridge.lastActiveRigId !== "undefined" ? bridge.lastActiveRigId : null; - return rig || "general"; - } - async function bmFetchOverlay() { - const overlayScope = bmListScope(); - try { - const resp = await fetch("/bookmarks" + bmScopeParam(false, overlayScope)); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - bmOverlayList = await resp.json(); - } catch (e) { - console.error("Failed to fetch overlay bookmarks:", e); - bmOverlayList = []; - } - bmOverlayRevision++; - if (typeof bridge.syncBookmarkMapLocators === "function") { - bridge.syncBookmarkMapLocators(bmOverlayList); - } - if (typeof bridge.scheduleSpectrumDraw === "function") bridge.scheduleSpectrumDraw(); - } - async function bmFetch(categoryFilter) { - let url = "/bookmarks"; - let hasQuery = false; - if (categoryFilter && categoryFilter !== "") { - url += "?category=" + encodeURIComponent(categoryFilter); - hasQuery = true; - } - url += bmScopeParam(hasQuery); - const overlayPromise = bmFetchOverlay(); - try { - const resp = await fetch(url); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - bmList = await resp.json(); - } catch (e) { - console.error("Failed to fetch bookmarks:", e); - bmList = []; - } - bmSelected.clear(); - bmUpdateSelectionUi(); - bmSyncAccess(); - bmApplyFilters(); - void bmRefreshCategoryFilter(categoryFilter); - await overlayPromise; - } - function bmApplyFilters() { - const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase(); - const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase(); - let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList; - filtered = text ? filtered.filter( - (bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text) - ) : filtered; - bmFilteredList = filtered; - bmCurrentPage = 1; - bmRender(filtered); - } - async function bmRefreshCategoryFilter(keepValue) { - const sel = bmEl("bm-category-filter"); - const modeSel = bmEl("bm-mode-filter"); - if (!sel && !modeSel) return; - try { - const resp = await fetch("/bookmarks" + bmScopeParam(false)); - if (!resp.ok) return; - const all = await resp.json(); - if (sel) { - const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort(); - while (sel.options.length > 1) sel.remove(1); - cats.forEach((cat) => { - const opt = document.createElement("option"); - opt.value = cat; - opt.textContent = cat; - sel.add(opt); - }); - if (keepValue && cats.includes(keepValue)) sel.value = keepValue; - } - if (modeSel) { - const keepMode = modeSel.value; - const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort(); - while (modeSel.options.length > 1) modeSel.remove(1); - modes.forEach((mode) => { - const opt = document.createElement("option"); - opt.value = mode; - opt.textContent = mode; - modeSel.add(opt); - }); - if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode; - } - } catch { - } - } - function bmRender(list) { - const tbody = bmEl("bm-tbody"); - const emptyEl = bmEl("bm-empty"); - const paginatorEl = bmEl("bm-paginator"); - const pageSummaryEl = bmEl("bm-page-summary"); - const pageIndicatorEl = bmEl("bm-page-indicator"); - const prevBtn = bmEl("bm-page-prev"); - const nextBtn = bmEl("bm-page-next"); - if (!tbody) return; - tbody.innerHTML = ""; - if (list.length === 0) { - if (emptyEl) emptyEl.style.display = ""; - if (paginatorEl) paginatorEl.style.display = "none"; - return; - } - if (emptyEl) emptyEl.style.display = "none"; - const canControl = bmCanControl(); - const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE)); - const page = Math.min(Math.max(bmCurrentPage, 1), totalPages); - bmCurrentPage = page; - const startIndex = (page - 1) * BM_PAGE_SIZE; - const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length); - const pageItems = list.slice(startIndex, endIndex); - const showScope = bmScope !== "general"; - pageItems.forEach((bm) => { - const tr = document.createElement("tr"); - tr.dataset.bmId = bm.id; - const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--"; - const locatorCell = bm.locator || "--"; - const catCell = bm.category || "Uncategorised"; - const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--"; - const commentCell = bm.comment || ""; - const checked = bmSelected.has(bm.id) ? " checked" : ""; - const scopeBadge = showScope && bm.scope === "general" ? ' G' : ""; - tr.innerHTML = `${bmEsc(bm.name)}${scopeBadge}${bmFmtFreq(bm.freq_hz)}${bmEsc(bm.mode)}${bwCell}${bmEsc(locatorCell)}${bmEsc(catCell)}${bmEsc(decoderCell)}${bmEsc(commentCell)}` + (canControl ? `` : "") + ``; - tbody.appendChild(tr); - }); - bmSyncSelectAllCheckbox(); - if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : ""; - if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`; - if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`; - if (prevBtn) prevBtn.disabled = page <= 1; - if (nextBtn) nextBtn.disabled = page >= totalPages; - } - function bmChangePage(delta) { - const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE)); - const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages); - if (nextPage === bmCurrentPage) return; - bmCurrentPage = nextPage; - bmRender(bmFilteredList); - } - function bmReadDecoders() { - return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id); - } - function bmWriteDecoders(decoders) { - const set = new Set(decoders || []); - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { - const el = bmEl("bm-dec-" + d.id); - if (el) el.checked = set.has(d.id); - }); - } - function bmBuildDecoderCheckboxes() { - const container = bmEl("bm-decoder-checkboxes"); - if (!container) return; - container.innerHTML = ""; - (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { - const label = document.createElement("label"); - label.className = "bm-decoder-check"; - label.innerHTML = ' ' + d.label; - container.appendChild(label); - }); - } - function bmOpenForm(bm) { - const wrap = bmEl("bm-form-wrap"); - if (!wrap) return; - bmEditScope = bm ? bm.scope || bmScope : null; - bmBuildDecoderCheckboxes(); - bmEl("bm-id").value = bm ? bm.id : ""; - bmEl("bm-name").value = bm ? bm.name : ""; - bmEl("bm-freq").value = bm ? String(bm.freq_hz) : ""; - bmEl("bm-mode").value = bm ? bm.mode : ""; - bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : ""; - bmEl("bm-locator").value = bm ? bm.locator || "" : ""; - bmEl("bm-category-input").value = bm ? bm.category || "" : ""; - bmEl("bm-comment").value = bm ? bm.comment || "" : ""; - bmWriteDecoders(bm?.decoders ?? []); - bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; - wrap.style.display = "flex"; - bmEl("bm-name").focus(); - } - function bmCloseForm() { - const wrap = bmEl("bm-form-wrap"); - if (wrap) wrap.style.display = "none"; - } - function bmPrefillFromStatus() { - if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { - bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); - } - if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { - bmEl("bm-mode").value = bridge.lastModeName; - } - if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { - bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); - } - const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { - const btn = bmEl(d.id + "-decode-toggle-btn"); - return btn && btn.dataset.enabled === "true"; - }).map((d) => d.id); - bmWriteDecoders(activeDecoders); - } - async function bmSave(e) { - e.preventDefault(); - const id = bmEl("bm-id").value; - const name = bmEl("bm-name").value.trim(); - const freqStr = bmEl("bm-freq").value; - const freq_hz = parseInt(freqStr, 10); - const mode = bmEl("bm-mode").value.trim(); - const bwStr = bmEl("bm-bw").value; - const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; - const locator = bmEl("bm-locator").value.trim().toUpperCase(); - const category = bmEl("bm-category-input").value.trim(); - const comment = bmEl("bm-comment").value.trim(); - const decoders = bmReadDecoders(); - const formError = bmEl("bm-form-error"); - if (formError) formError.textContent = ""; - if (!name || !Number.isFinite(freq_hz) || !mode) { - if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; - const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode"); - invalid?.focus(); - return; - } - const body = { - name, - freq_hz, - mode, - bandwidth_hz, - locator: locator || null, - category, - comment, - decoders - }; - try { - let resp; - if (id) { - resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) - }); - } else { - resp = await fetch("/bookmarks" + bmScopeParam(false), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body) - }); - } - if (!resp.ok) { - const text = await resp.text(); - if (resp.status === 409) { - throw new Error("A bookmark for that frequency already exists."); - } - throw new Error(text || `HTTP ${resp.status}`); - } - bmCloseForm(); - await bmFetch(bmEl("bm-category-filter").value); - } catch (err) { - console.error("Failed to save bookmark:", err); - if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err); - bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" }); - } - } - async function bmDelete(id) { - if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; - const bm = bmList.find((b) => b.id === id); - const scope = bm ? bm.scope : void 0; - try { - const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), { - method: "DELETE" + bmSelected.clear(); + bmUpdateSelectionUi(); + bmSyncAccess(); + bmApplyFilters(); + void bmRefreshCategoryFilter(categoryFilter); + await overlayPromise; +} +function bmApplyFilters() { + const text = (bmEl("bm-text-filter")?.value || "").trim().toLowerCase(); + const modeFilter = (bmEl("bm-mode-filter")?.value || "").trim().toUpperCase(); + let filtered = modeFilter ? bmList.filter((bm) => (bm.mode || "").toUpperCase() === modeFilter) : bmList; + filtered = text ? filtered.filter( + (bm) => (bm.name || "").toLowerCase().includes(text) || (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text) + ) : filtered; + bmFilteredList = filtered; + bmCurrentPage = 1; + bmRender(filtered); +} +async function bmRefreshCategoryFilter(keepValue) { + const sel = bmEl("bm-category-filter"); + const modeSel = bmEl("bm-mode-filter"); + if (!sel && !modeSel) return; + try { + const resp = await fetch("/bookmarks" + bmScopeParam(false)); + if (!resp.ok) return; + const all = await resp.json(); + if (sel) { + const cats = [...new Set(all.map((b) => b.category || "").filter(Boolean))].sort(); + while (sel.options.length > 1) sel.remove(1); + cats.forEach((cat) => { + const opt = document.createElement("option"); + opt.value = cat; + opt.textContent = cat; + sel.add(opt); }); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - await bmFetch(bmEl("bm-category-filter").value); - } catch (err) { - console.error("Failed to delete bookmark:", err); - bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" }); + if (keepValue && cats.includes(keepValue)) sel.value = keepValue; } + if (modeSel) { + const keepMode = modeSel.value; + const modes = [...new Set(all.map((b) => (b.mode || "").trim().toUpperCase()).filter(Boolean))].sort(); + while (modeSel.options.length > 1) modeSel.remove(1); + modes.forEach((mode) => { + const opt = document.createElement("option"); + opt.value = mode; + opt.textContent = mode; + modeSel.add(opt); + }); + if (keepMode && modes.includes(keepMode)) modeSel.value = keepMode; + } + } catch { } - function bmApply(bm) { - try { - if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { - bridge.modeEl.value = (bm.mode || "").toUpperCase(); +} +function bmRender(list) { + const tbody = bmEl("bm-tbody"); + const emptyEl = bmEl("bm-empty"); + const paginatorEl = bmEl("bm-paginator"); + const pageSummaryEl = bmEl("bm-page-summary"); + const pageIndicatorEl = bmEl("bm-page-indicator"); + const prevBtn = bmEl("bm-page-prev"); + const nextBtn = bmEl("bm-page-next"); + if (!tbody) return; + tbody.innerHTML = ""; + if (list.length === 0) { + if (emptyEl) emptyEl.style.display = ""; + if (paginatorEl) paginatorEl.style.display = "none"; + return; + } + if (emptyEl) emptyEl.style.display = "none"; + const canControl = bmCanControl(); + const totalPages = Math.max(1, Math.ceil(list.length / BM_PAGE_SIZE)); + const page = Math.min(Math.max(bmCurrentPage, 1), totalPages); + bmCurrentPage = page; + const startIndex = (page - 1) * BM_PAGE_SIZE; + const endIndex = Math.min(startIndex + BM_PAGE_SIZE, list.length); + const pageItems = list.slice(startIndex, endIndex); + const showScope = bmScope !== "general"; + pageItems.forEach((bm) => { + const tr = document.createElement("tr"); + tr.dataset.bmId = bm.id; + const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--"; + const locatorCell = bm.locator || "--"; + const catCell = bm.category || "Uncategorised"; + const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--"; + const commentCell = bm.comment || ""; + const checked = bmSelected.has(bm.id) ? " checked" : ""; + const scopeBadge = showScope && bm.scope === "general" ? ' G' : ""; + tr.innerHTML = `${bmEsc(bm.name)}${scopeBadge}${bmFmtFreq(bm.freq_hz)}${bmEsc(bm.mode)}${bwCell}${bmEsc(locatorCell)}${bmEsc(catCell)}${bmEsc(decoderCell)}${bmEsc(commentCell)}` + (canControl ? `` : "") + ``; + tbody.appendChild(tr); + }); + bmSyncSelectAllCheckbox(); + if (paginatorEl) paginatorEl.style.display = totalPages > 1 ? "flex" : ""; + if (pageSummaryEl) pageSummaryEl.textContent = `Showing ${startIndex + 1}-${endIndex} of ${list.length}`; + if (pageIndicatorEl) pageIndicatorEl.textContent = `Page ${page} of ${totalPages}`; + if (prevBtn) prevBtn.disabled = page <= 1; + if (nextBtn) nextBtn.disabled = page >= totalPages; +} +function bmChangePage(delta) { + const totalPages = Math.max(1, Math.ceil(bmFilteredList.length / BM_PAGE_SIZE)); + const nextPage = Math.min(Math.max(bmCurrentPage + delta, 1), totalPages); + if (nextPage === bmCurrentPage) return; + bmCurrentPage = nextPage; + bmRender(bmFilteredList); +} +function bmReadDecoders() { + return (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).filter((d) => bmEl("bm-dec-" + d.id)?.checked).map((d) => d.id); +} +function bmWriteDecoders(decoders) { + const set = new Set(decoders || []); + (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + const el = bmEl("bm-dec-" + d.id); + if (el) el.checked = set.has(d.id); + }); +} +function bmBuildDecoderCheckboxes() { + const container = bmEl("bm-decoder-checkboxes"); + if (!container) return; + container.innerHTML = ""; + (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable).forEach((d) => { + const label = document.createElement("label"); + label.className = "bm-decoder-check"; + label.innerHTML = ' ' + d.label; + container.appendChild(label); + }); +} +function bmOpenForm(bm) { + const wrap = bmEl("bm-form-wrap"); + if (!wrap) return; + bmEditScope = bm ? bm.scope || bmScope : null; + bmBuildDecoderCheckboxes(); + bmEl("bm-id").value = bm ? bm.id : ""; + bmEl("bm-name").value = bm ? bm.name : ""; + bmEl("bm-freq").value = bm ? String(bm.freq_hz) : ""; + bmEl("bm-mode").value = bm ? bm.mode : ""; + bmEl("bm-bw").value = bm?.bandwidth_hz ? String(bm.bandwidth_hz) : ""; + bmEl("bm-locator").value = bm ? bm.locator || "" : ""; + bmEl("bm-category-input").value = bm ? bm.category || "" : ""; + bmEl("bm-comment").value = bm ? bm.comment || "" : ""; + bmWriteDecoders(bm?.decoders ?? []); + bmEl("bm-form-title").textContent = bm ? "Edit Bookmark" : "Add Bookmark"; + wrap.style.display = "flex"; + bmEl("bm-name").focus(); +} +function bmCloseForm() { + const wrap = bmEl("bm-form-wrap"); + if (wrap) wrap.style.display = "none"; +} +function bmPrefillFromStatus() { + if (typeof bridge.lastFreqHz === "number" && Number.isFinite(bridge.lastFreqHz)) { + bmEl("bm-freq").value = String(Math.round(bridge.lastFreqHz)); + } + if (typeof bridge.lastModeName === "string" && bridge.lastModeName) { + bmEl("bm-mode").value = bridge.lastModeName; + } + if (typeof bridge.currentBandwidthHz === "number" && bridge.currentBandwidthHz > 0) { + bmEl("bm-bw").value = String(Math.round(bridge.currentBandwidthHz)); + } + const activeDecoders = (bridge.decoderRegistry || []).filter((d) => d.bookmark_selectable && d.activation === "toggle").filter((d) => { + const btn = bmEl(d.id + "-decode-toggle-btn"); + return btn && btn.dataset.enabled === "true"; + }).map((d) => d.id); + bmWriteDecoders(activeDecoders); +} +async function bmSave(e) { + e.preventDefault(); + const id = bmEl("bm-id").value; + const name = bmEl("bm-name").value.trim(); + const freqStr = bmEl("bm-freq").value; + const freq_hz = parseInt(freqStr, 10); + const mode = bmEl("bm-mode").value.trim(); + const bwStr = bmEl("bm-bw").value; + const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; + const locator = bmEl("bm-locator").value.trim().toUpperCase(); + const category = bmEl("bm-category-input").value.trim(); + const comment = bmEl("bm-comment").value.trim(); + const decoders = bmReadDecoders(); + const formError = bmEl("bm-form-error"); + if (formError) formError.textContent = ""; + if (!name || !Number.isFinite(freq_hz) || !mode) { + if (formError) formError.textContent = "Enter a name, a valid frequency, and a mode."; + const invalid = !name ? bmEl("bm-name") : !Number.isFinite(freq_hz) ? bmEl("bm-freq") : bmEl("bm-mode"); + invalid?.focus(); + return; + } + const body = { + name, + freq_hz, + mode, + bandwidth_hz, + locator: locator || null, + category, + comment, + decoders + }; + try { + let resp; + if (id) { + resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, bmEditScope), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } else { + resp = await fetch("/bookmarks" + bmScopeParam(false), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } + if (!resp.ok) { + const text = await resp.text(); + if (resp.status === 409) { + throw new Error("A bookmark for that frequency already exists."); + } + throw new Error(text || `HTTP ${resp.status}`); + } + bmCloseForm(); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to save bookmark:", err); + if (formError) formError.textContent = "Failed to save bookmark: " + errorMessage(err); + bridge.trxUi.notify?.("Bookmark could not be saved", { kind: "error" }); + } +} +async function bmDelete(id) { + if (!await bridge.trxUi.confirm({ title: "Delete bookmark?", message: "This bookmark will be permanently removed.", confirmLabel: "Delete" })) return; + const bm = bmList.find((b) => b.id === id); + const scope = bm ? bm.scope : void 0; + try { + const resp = await fetch("/bookmarks/" + encodeURIComponent(id) + bmScopeParam(false, scope), { + method: "DELETE" + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to delete bookmark:", err); + bridge.trxUi.notify?.("Failed to delete bookmark: " + errorMessage(err), { kind: "error" }); + } +} +function bmApply(bm) { + try { + if (typeof bridge.modeEl !== "undefined" && bridge.modeEl) { + bridge.modeEl.value = (bm.mode || "").toUpperCase(); + } + if (bm.bandwidth_hz) { + if (typeof bridge.currentBandwidthHz !== "undefined") { + bridge.currentBandwidthHz = bm.bandwidth_hz; + } + bridge.currentBandwidthHz = bm.bandwidth_hz; + if (typeof bridge.syncBandwidthInput === "function") { + bridge.syncBandwidthInput(bm.bandwidth_hz); + } + } + if (typeof bridge.applyLocalTunedFrequency === "function") { + if (typeof bridge._freqOptimisticSeq !== "undefined") { + ++bridge._freqOptimisticSeq; + bridge._freqOptimisticHz = bm.freq_hz; + } + bridge.applyLocalTunedFrequency(bm.freq_hz, true); + } + if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { + bridge.scheduleSpectrumDraw(); + } + const tunePromise = (async () => { + if (typeof bridge.vchanTakeSchedulerControl === "function") { + await bridge.vchanTakeSchedulerControl(); + } + const onVirtual = typeof bridge.vchanInterceptMode === "function" && await bridge.vchanInterceptMode(bm.mode); + if (!onVirtual) { + await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); } if (bm.bandwidth_hz) { - if (typeof bridge.currentBandwidthHz !== "undefined") { - bridge.currentBandwidthHz = bm.bandwidth_hz; - } - bridge.currentBandwidthHz = bm.bandwidth_hz; - if (typeof bridge.syncBandwidthInput === "function") { - bridge.syncBandwidthInput(bm.bandwidth_hz); + const bwHandledByVchan = typeof bridge.vchanInterceptBandwidth === "function" && await bridge.vchanInterceptBandwidth(bm.bandwidth_hz); + if (!bwHandledByVchan) { + await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); } } - if (typeof bridge.applyLocalTunedFrequency === "function") { - if (typeof bridge._freqOptimisticSeq !== "undefined") { - ++bridge._freqOptimisticSeq; - bridge._freqOptimisticHz = bm.freq_hz; - } - bridge.applyLocalTunedFrequency(bm.freq_hz, true); - } - if (typeof bridge.scheduleSpectrumDraw === "function" && typeof bridge.lastSpectrumData !== "undefined" && bridge.lastSpectrumData) { - bridge.scheduleSpectrumDraw(); - } - const tunePromise = (async () => { - if (typeof bridge.vchanTakeSchedulerControl === "function") { - await bridge.vchanTakeSchedulerControl(); - } - const onVirtual = typeof bridge.vchanInterceptMode === "function" && await bridge.vchanInterceptMode(bm.mode); - if (!onVirtual) { - await bridge.postPath("/set_mode?mode=" + encodeURIComponent(bm.mode)); - } - if (bm.bandwidth_hz) { - const bwHandledByVchan = typeof bridge.vchanInterceptBandwidth === "function" && await bridge.vchanInterceptBandwidth(bm.bandwidth_hz); - if (!bwHandledByVchan) { - await bridge.postPath(`/set_bandwidth?hz=${bm.bandwidth_hz}`); - } - } - if (typeof bridge.setRigFrequency === "function") { - await bridge.setRigFrequency(bm.freq_hz); - } else { - await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); - } - })(); - const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; - const modeUp = (bm.mode || "").toUpperCase(); - const allToggleDecoders = (bridge.decoderRegistry || []).filter( - (d) => d.activation === "toggle" - ); - const decoderPromise = allToggleDecoders.length ? (async () => { - let statusUrl = "/status"; - if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { - statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); - } - const statusResp = await fetch(statusUrl); - if (!statusResp.ok) return; - const st = await statusResp.json(); - const toggles = []; - for (const d of allToggleDecoders) { - const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled"; - const currentlyOn = !!st[statusKey]; - const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp); - let wanted; - if (!compatible) { - wanted = false; - } else if (hasDecoders) { - wanted = bm.decoders?.includes(d.id) ?? false; - } else { - wanted = currentlyOn; - } - if (wanted !== currentlyOn) { - toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); - } - } - if (toggles.length) await Promise.all(toggles); - })() : Promise.resolve(); - void Promise.all([tunePromise, decoderPromise]).catch((error) => { - console.error("Bookmark apply background error:", error); - }); - } catch (err) { - console.error("Failed to apply bookmark:", err); - } - } - bridge.trx ??= {}; - bridge.trx.modules ??= {}; - bridge.trx.modules.bookmarks = { - get overlayList() { - return bmOverlayList; - }, - get overlayRevision() { - return bmOverlayRevision; - }, - refreshOverlay: bmFetchOverlay, - invalidateColors() { - bmOverlayRevision += 1; - }, - apply: bmApply - }; - function bmUpdateSelectionUi() { - const count = bmSelected.size; - const canCtrl = bmCanControl(); - const visible = count > 0 && canCtrl; - const btn = bmEl("bm-del-selected-btn"); - const countEl = bmEl("bm-del-selected-count"); - if (btn) btn.style.display = visible ? "" : "none"; - if (countEl) countEl.textContent = String(count); - const moveWrap = bmEl("bm-move-selected-wrap"); - const moveCountEl = bmEl("bm-move-selected-count"); - if (moveWrap) moveWrap.style.display = visible ? "" : "none"; - if (moveCountEl) moveCountEl.textContent = String(count); - if (visible) bmPopulateMoveTarget(); - const selectAllBtn = bmEl("bm-select-all-btn"); - if (selectAllBtn && bmCanControl()) { - const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); - selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All"; - } - } - function bmPopulateMoveTarget() { - const sel = bmEl("bm-move-target"); - if (!sel) return; - const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; - const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; - const prev = sel.value; - sel.innerHTML = ""; - if (bmScope !== "general") { - const opt = document.createElement("option"); - opt.value = "general"; - opt.textContent = "General"; - sel.appendChild(opt); - } - rigIds.forEach((id) => { - if (id === bmScope) return; - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = displayNames[id] || id; - sel.appendChild(opt); - }); - if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) { - sel.value = prev; - } - } - async function bmMoveSelected() { - const ids = Array.from(bmSelected); - if (ids.length === 0) return; - const target = bmEl("bm-move-target")?.value; - if (!target) return; - const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target; - if (!await bridge.trxUi.confirm({ - title: "Move selected bookmarks?", - message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`, - confirmLabel: "Move", - danger: false - })) return; - try { - const byScope = {}; - for (const id of ids) { - const bm = bmList.find((b) => b.id === id); - const scope = bm?.scope || bmScope; - if (scope === target) continue; - (byScope[scope] ||= []).push(id); - } - await Promise.all(Object.entries(byScope).map( - ([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids: scopeIds, to: target }) - }).then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - }) - )); - bmSelected.clear(); - bmUpdateSelectionUi(); - await bmFetch(bmEl("bm-category-filter").value); - } catch (err) { - console.error("Failed to move bookmarks:", err); - bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" }); - } - } - function bmSyncSelectAllCheckbox() { - const selectAll = bmEl("bm-select-all"); - if (!selectAll) return; - const checkboxes = document.querySelectorAll(".bm-row-sel"); - if (checkboxes.length === 0) { - selectAll.checked = false; - selectAll.indeterminate = false; - return; - } - const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length; - selectAll.checked = checkedCount === checkboxes.length; - selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length; - } - async function bmDeleteSelected() { - const ids = Array.from(bmSelected); - if (ids.length === 0) return; - if (!await bridge.trxUi.confirm({ - title: "Delete selected bookmarks?", - message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`, - confirmLabel: "Delete" - })) return; - try { - const byScope = {}; - for (const id of ids) { - const bm = bmList.find((b) => b.id === id); - const scope = bm?.scope || bmScope; - (byScope[scope] ||= []).push(id); - } - await Promise.all(Object.entries(byScope).map( - ([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ids: scopeIds }) - }).then((r) => { - if (!r.ok) throw new Error(`HTTP ${r.status}`); - }) - )); - bmSelected.clear(); - bmUpdateSelectionUi(); - await bmFetch(bmEl("bm-category-filter").value); - } catch (err) { - console.error("Failed to delete bookmarks:", err); - bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" }); - } - } - function bmPopulateScopePicker() { - const picker = bmEl("bm-scope-picker"); - if (!picker) return; - const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; - const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; - const prev = picker.value; - while (picker.options.length > 1) picker.remove(1); - rigIds.forEach((id) => { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = displayNames[id] || id; - picker.appendChild(opt); - }); - if (prev && (prev === "general" || rigIds.includes(prev))) { - picker.value = prev; - } else { - picker.value = "general"; - } - bmScope = picker.value; - } - (function initBookmarks() { - bmSyncAccess(); - bmBuildDecoderCheckboxes(); - if (typeof bridge.onDecoderRegistryReady === "function") { - bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); - } - bmPopulateScopePicker(); - const scopePicker = bmEl("bm-scope-picker"); - if (scopePicker) { - scopePicker.addEventListener("change", (e) => { - bmScope = e.currentTarget.value; - void bmFetch(bmEl("bm-category-filter").value || ""); - }); - } - document.querySelector(".tab-bar")?.addEventListener("click", (e) => { - const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null; - if (!btn) return; - void bmFetch(bmEl("bm-category-filter").value); - }); - bmEl("bm-add-btn").addEventListener("click", () => { - bmOpenForm(null); - bmPrefillFromStatus(); - }); - bmEl("bm-category-filter").addEventListener("change", (e) => { - void bmFetch(e.currentTarget.value); - }); - bmEl("bm-mode-filter").addEventListener("change", () => { - bmApplyFilters(); - }); - bmEl("bm-text-filter").addEventListener("input", () => { - bmApplyFilters(); - }); - bmEl("bm-page-prev").addEventListener("click", () => { - bmChangePage(-1); - }); - bmEl("bm-page-next").addEventListener("click", () => { - bmChangePage(1); - }); - bmEl("bm-form").addEventListener("submit", (event) => { - void bmSave(event); - }); - bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); - const formWrap = bmEl("bm-form-wrap"); - if (formWrap) { - formWrap.addEventListener("click", (e) => { - if (e.target === formWrap) bmCloseForm(); - }); - } - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") { - bmCloseForm(); - } - }); - bmEl("bm-select-all").addEventListener("change", (e) => { - const checked = e.currentTarget.checked; - document.querySelectorAll(".bm-row-sel").forEach((cb) => { - cb.checked = checked; - const id = cb.dataset.bmId; - if (!id) return; - if (checked) bmSelected.add(id); - else bmSelected.delete(id); - }); - bmUpdateSelectionUi(); - }); - bmEl("bm-select-all-btn").addEventListener("click", () => { - const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); - if (allSelected) { - bmSelected.clear(); + if (typeof bridge.setRigFrequency === "function") { + await bridge.setRigFrequency(bm.freq_hz); } else { - bmFilteredList.forEach((bm) => bmSelected.add(bm.id)); + await bridge.postPath(`/set_freq?hz=${bm.freq_hz}`); } - document.querySelectorAll(".bm-row-sel").forEach((cb) => { - cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId); - }); - bmSyncSelectAllCheckbox(); - bmUpdateSelectionUi(); - }); - bmEl("bm-del-selected-btn").addEventListener("click", () => { - void bmDeleteSelected(); - }); - bmEl("bm-move-selected-btn").addEventListener("click", () => { - void bmMoveSelected(); - }); - bmEl("bm-tbody").addEventListener("click", (e) => { - void (async () => { - if (!(e.target instanceof Element)) return; - const checkbox = e.target.closest(".bm-row-sel"); - if (checkbox) { - const id = checkbox.dataset.bmId; - if (!id) return; - if (checkbox.checked) bmSelected.add(id); - else bmSelected.delete(id); - bmSyncSelectAllCheckbox(); - bmUpdateSelectionUi(); - return; + })(); + const hasDecoders = Array.isArray(bm.decoders) && bm.decoders.length > 0; + const modeUp = (bm.mode || "").toUpperCase(); + const allToggleDecoders = (bridge.decoderRegistry || []).filter( + (d) => d.activation === "toggle" + ); + const decoderPromise = allToggleDecoders.length ? (async () => { + let statusUrl = "/status"; + if (typeof bridge.lastActiveRigId !== "undefined" && bridge.lastActiveRigId) { + statusUrl += "?remote=" + encodeURIComponent(bridge.lastActiveRigId); + } + const statusResp = await fetch(statusUrl); + if (!statusResp.ok) return; + const st = await statusResp.json(); + const toggles = []; + for (const d of allToggleDecoders) { + const statusKey = d.id.replace(/-/g, "_") + "_decode_enabled"; + const currentlyOn = !!st[statusKey]; + const compatible = Array.isArray(d.active_modes) && d.active_modes.includes(modeUp); + let wanted; + if (!compatible) { + wanted = false; + } else if (hasDecoders) { + wanted = bm.decoders?.includes(d.id) ?? false; + } else { + wanted = currentlyOn; } - const tuneBtn = e.target.closest(".bm-tune-btn"); - const editBtn = e.target.closest(".bm-edit-btn"); - const delBtn = e.target.closest(".bm-del-btn"); - if (tuneBtn) { - const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId); - if (bm) bmApply(bm); - } else if (editBtn) { - const bm = bmList.find((b) => b.id === editBtn.dataset.bmId); - if (bm) bmOpenForm(bm); - } else if (delBtn) { - const id = delBtn.dataset.bmId; - if (id) await bmDelete(id); + if (wanted !== currentlyOn) { + toggles.push(bridge.postPath("/toggle_" + d.id.replace(/-/g, "_") + "_decode")); } - })(); + } + if (toggles.length) await Promise.all(toggles); + })() : Promise.resolve(); + void Promise.all([tunePromise, decoderPromise]).catch((error) => { + console.error("Bookmark apply background error:", error); }); - void bmFetch(""); - })(); + } catch (err) { + console.error("Failed to apply bookmark:", err); + } +} +bridge.trx ??= {}; +bridge.trx.modules ??= {}; +bridge.trx.modules.bookmarks = { + get overlayList() { + return bmOverlayList; + }, + get overlayRevision() { + return bmOverlayRevision; + }, + refreshOverlay: bmFetchOverlay, + invalidateColors() { + bmOverlayRevision += 1; + }, + apply: bmApply +}; +function bmUpdateSelectionUi() { + const count = bmSelected.size; + const canCtrl = bmCanControl(); + const visible = count > 0 && canCtrl; + const btn = bmEl("bm-del-selected-btn"); + const countEl = bmEl("bm-del-selected-count"); + if (btn) btn.style.display = visible ? "" : "none"; + if (countEl) countEl.textContent = String(count); + const moveWrap = bmEl("bm-move-selected-wrap"); + const moveCountEl = bmEl("bm-move-selected-count"); + if (moveWrap) moveWrap.style.display = visible ? "" : "none"; + if (moveCountEl) moveCountEl.textContent = String(count); + if (visible) bmPopulateMoveTarget(); + const selectAllBtn = bmEl("bm-select-all-btn"); + if (selectAllBtn && bmCanControl()) { + const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); + selectAllBtn.textContent = allSelected ? "Deselect All" : "Select All"; + } +} +function bmPopulateMoveTarget() { + const sel = bmEl("bm-move-target"); + if (!sel) return; + const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; + const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const prev = sel.value; + sel.innerHTML = ""; + if (bmScope !== "general") { + const opt = document.createElement("option"); + opt.value = "general"; + opt.textContent = "General"; + sel.appendChild(opt); + } + rigIds.forEach((id) => { + if (id === bmScope) return; + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = displayNames[id] || id; + sel.appendChild(opt); + }); + if (prev && sel.querySelector(`option[value="${CSS.escape(prev)}"]`)) { + sel.value = prev; + } +} +async function bmMoveSelected() { + const ids = Array.from(bmSelected); + if (ids.length === 0) return; + const target = bmEl("bm-move-target")?.value; + if (!target) return; + const targetLabel = bmEl("bm-move-target")?.selectedOptions[0]?.textContent || target; + if (!await bridge.trxUi.confirm({ + title: "Move selected bookmarks?", + message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will move to “${targetLabel}”.`, + confirmLabel: "Move", + danger: false + })) return; + try { + const byScope = {}; + for (const id of ids) { + const bm = bmList.find((b) => b.id === id); + const scope = bm?.scope || bmScope; + if (scope === target) continue; + (byScope[scope] ||= []).push(id); + } + await Promise.all(Object.entries(byScope).map( + ([scope, scopeIds]) => fetch("/bookmarks/batch_move" + bmScopeParam(false, scope), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: scopeIds, to: target }) + }).then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + )); + bmSelected.clear(); + bmUpdateSelectionUi(); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to move bookmarks:", err); + bridge.trxUi.notify?.("Failed to move bookmarks: " + errorMessage(err), { kind: "error" }); + } +} +function bmSyncSelectAllCheckbox() { + const selectAll = bmEl("bm-select-all"); + if (!selectAll) return; + const checkboxes = document.querySelectorAll(".bm-row-sel"); + if (checkboxes.length === 0) { + selectAll.checked = false; + selectAll.indeterminate = false; + return; + } + const checkedCount = Array.from(checkboxes).filter((cb) => cb.checked).length; + selectAll.checked = checkedCount === checkboxes.length; + selectAll.indeterminate = checkedCount > 0 && checkedCount < checkboxes.length; +} +async function bmDeleteSelected() { + const ids = Array.from(bmSelected); + if (ids.length === 0) return; + if (!await bridge.trxUi.confirm({ + title: "Delete selected bookmarks?", + message: `${ids.length} bookmark${ids.length > 1 ? "s" : ""} will be permanently removed.`, + confirmLabel: "Delete" + })) return; + try { + const byScope = {}; + for (const id of ids) { + const bm = bmList.find((b) => b.id === id); + const scope = bm?.scope || bmScope; + (byScope[scope] ||= []).push(id); + } + await Promise.all(Object.entries(byScope).map( + ([scope, scopeIds]) => fetch("/bookmarks/batch_delete" + bmScopeParam(false, scope), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ids: scopeIds }) + }).then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + }) + )); + bmSelected.clear(); + bmUpdateSelectionUi(); + await bmFetch(bmEl("bm-category-filter").value); + } catch (err) { + console.error("Failed to delete bookmarks:", err); + bridge.trxUi.notify?.("Failed to delete bookmarks: " + errorMessage(err), { kind: "error" }); + } +} +function bmPopulateScopePicker() { + const picker = bmEl("bm-scope-picker"); + if (!picker) return; + const rigIds = typeof bridge.lastRigIds !== "undefined" && Array.isArray(bridge.lastRigIds) ? bridge.lastRigIds : []; + const displayNames = typeof bridge.lastRigDisplayNames !== "undefined" ? bridge.lastRigDisplayNames : {}; + const prev = picker.value; + while (picker.options.length > 1) picker.remove(1); + rigIds.forEach((id) => { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = displayNames[id] || id; + picker.appendChild(opt); + }); + if (prev && (prev === "general" || rigIds.includes(prev))) { + picker.value = prev; + } else { + picker.value = "general"; + } + bmScope = picker.value; +} +(function initBookmarks() { + bmSyncAccess(); + bmBuildDecoderCheckboxes(); + if (typeof bridge.onDecoderRegistryReady === "function") { + bridge.onDecoderRegistryReady(bmBuildDecoderCheckboxes); + } + bmPopulateScopePicker(); + const scopePicker = bmEl("bm-scope-picker"); + if (scopePicker) { + scopePicker.addEventListener("change", (e) => { + bmScope = e.currentTarget.value; + void bmFetch(bmEl("bm-category-filter").value || ""); + }); + } + document.querySelector(".tab-bar")?.addEventListener("click", (e) => { + const btn = e.target instanceof Element ? e.target.closest('.tab[data-tab="bookmarks"]') : null; + if (!btn) return; + void bmFetch(bmEl("bm-category-filter").value); + }); + bmEl("bm-add-btn").addEventListener("click", () => { + bmOpenForm(null); + bmPrefillFromStatus(); + }); + bmEl("bm-category-filter").addEventListener("change", (e) => { + void bmFetch(e.currentTarget.value); + }); + bmEl("bm-mode-filter").addEventListener("change", () => { + bmApplyFilters(); + }); + bmEl("bm-text-filter").addEventListener("input", () => { + bmApplyFilters(); + }); + bmEl("bm-page-prev").addEventListener("click", () => { + bmChangePage(-1); + }); + bmEl("bm-page-next").addEventListener("click", () => { + bmChangePage(1); + }); + bmEl("bm-form").addEventListener("submit", (event) => { + void bmSave(event); + }); + bmEl("bm-form-cancel").addEventListener("click", bmCloseForm); + const formWrap = bmEl("bm-form-wrap"); + if (formWrap) { + formWrap.addEventListener("click", (e) => { + if (e.target === formWrap) bmCloseForm(); + }); + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && bmEl("bm-form-wrap")?.style.display === "flex") { + bmCloseForm(); + } + }); + bmEl("bm-select-all").addEventListener("change", (e) => { + const checked = e.currentTarget.checked; + document.querySelectorAll(".bm-row-sel").forEach((cb) => { + cb.checked = checked; + const id = cb.dataset.bmId; + if (!id) return; + if (checked) bmSelected.add(id); + else bmSelected.delete(id); + }); + bmUpdateSelectionUi(); + }); + bmEl("bm-select-all-btn").addEventListener("click", () => { + const allSelected = bmFilteredList.length > 0 && bmFilteredList.every((bm) => bmSelected.has(bm.id)); + if (allSelected) { + bmSelected.clear(); + } else { + bmFilteredList.forEach((bm) => bmSelected.add(bm.id)); + } + document.querySelectorAll(".bm-row-sel").forEach((cb) => { + cb.checked = !!cb.dataset.bmId && bmSelected.has(cb.dataset.bmId); + }); + bmSyncSelectAllCheckbox(); + bmUpdateSelectionUi(); + }); + bmEl("bm-del-selected-btn").addEventListener("click", () => { + void bmDeleteSelected(); + }); + bmEl("bm-move-selected-btn").addEventListener("click", () => { + void bmMoveSelected(); + }); + bmEl("bm-tbody").addEventListener("click", (e) => { + void (async () => { + if (!(e.target instanceof Element)) return; + const checkbox = e.target.closest(".bm-row-sel"); + if (checkbox) { + const id = checkbox.dataset.bmId; + if (!id) return; + if (checkbox.checked) bmSelected.add(id); + else bmSelected.delete(id); + bmSyncSelectAllCheckbox(); + bmUpdateSelectionUi(); + return; + } + const tuneBtn = e.target.closest(".bm-tune-btn"); + const editBtn = e.target.closest(".bm-edit-btn"); + const delBtn = e.target.closest(".bm-del-btn"); + if (tuneBtn) { + const bm = bmList.find((b) => b.id === tuneBtn.dataset.bmId); + if (bm) bmApply(bm); + } else if (editBtn) { + const bm = bmList.find((b) => b.id === editBtn.dataset.bmId); + if (bm) bmOpenForm(bm); + } else if (delBtn) { + const id = delBtn.dataset.bmId; + if (id) await bmDelete(id); + } + })(); + }); + void bmFetch(""); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js new file mode 100644 index 00000000..1d386a5f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js @@ -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)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; +} +function renderAprsCharacter(character) { + const code = character.charCodeAt(0); + return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; +} +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 `${symbol}`; +} +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 +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js new file mode 100644 index 00000000..1fa6534f --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-SGMG5LG2.js @@ -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) ? `${grid}` : 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 = `
${label}LiveLast 15 minutesClear
${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 = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; + 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 ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; + 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 += `
${time}${renderMessage(message.message ?? "")}${detail ? ` · ${detail}` : ""}
`; + } + 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 +}; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js index 562ae85d..514dd896 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/cw.js @@ -1,403 +1,400 @@ -"use strict"; -(() => { - // src/plugins/cw.ts - var cwWindow = window; - var cwStatusEl = document.getElementById("cw-status"); - var cwOutputEl = document.getElementById("cw-output"); - var cwAutoInput = document.getElementById("cw-auto"); - var cwWpmInput = document.getElementById("cw-wpm"); - var cwToneInput = document.getElementById("cw-tone"); - var cwSignalIndicator = document.getElementById("cw-signal-indicator"); - var cwToneCanvas = document.getElementById("cw-tone-waterfall"); - var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; - var cwTonePickerEl = document.querySelector(".cw-tone-picker"); - var cwToneRangeEl = document.getElementById("cw-tone-range"); - var cwBarOverlay = document.getElementById("cw-bar-overlay"); - var CW_MAX_LINES = 200; - var CW_TONE_MIN_HZ = 100; - var CW_TONE_MAX_HZ = 1e4; - var CW_WPM_MIN = 5; - var CW_WPM_MAX = 40; - var CW_BAR_WINDOW_MS = 15 * 60 * 1e3; - var CW_BAR_LINE_GAP_MS = 5e3; - var cwLastAppendTime = 0; - var cwTonePickerRaf = null; - var cwBarHistory = []; - var cwBarCurrentLine = null; - var cwBarDismissedAtMs = 0; - var cwAutoLocalOverride = null; - function escapeCwHtml(input) { - return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); - } - 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 = `
CWLiveLast 15 minutesClear
`; - for (const line of liveLines.slice(0, 8)) { - const ts = line.ts ? `${line.ts}` : ""; - const meta = [ - line.wpm ? `${String(line.wpm)} WPM` : null, - line.tone_hz ? `${String(line.tone_hz)} Hz` : null - ].filter(Boolean).join(" · "); - html += `
${ts}${escapeCwHtml(line.text)}` + (meta ? ` ${escapeCwHtml(meta)}` : "") + `
`; - } - 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; - } - })(); - }); - } +// src/plugins/cw.ts +var cwWindow = window; +var cwStatusEl = document.getElementById("cw-status"); +var cwOutputEl = document.getElementById("cw-output"); +var cwAutoInput = document.getElementById("cw-auto"); +var cwWpmInput = document.getElementById("cw-wpm"); +var cwToneInput = document.getElementById("cw-tone"); +var cwSignalIndicator = document.getElementById("cw-signal-indicator"); +var cwToneCanvas = document.getElementById("cw-tone-waterfall"); +var cwToneGl = cwToneCanvas && cwWindow.createTrxWebGlRenderer ? cwWindow.createTrxWebGlRenderer(cwToneCanvas, { alpha: true }) : null; +var cwTonePickerEl = document.querySelector(".cw-tone-picker"); +var cwToneRangeEl = document.getElementById("cw-tone-range"); +var cwBarOverlay = document.getElementById("cw-bar-overlay"); +var CW_MAX_LINES = 200; +var CW_TONE_MIN_HZ = 100; +var CW_TONE_MAX_HZ = 1e4; +var CW_WPM_MIN = 5; +var CW_WPM_MAX = 40; +var CW_BAR_WINDOW_MS = 15 * 60 * 1e3; +var CW_BAR_LINE_GAP_MS = 5e3; +var cwLastAppendTime = 0; +var cwTonePickerRaf = null; +var cwBarHistory = []; +var cwBarCurrentLine = null; +var cwBarDismissedAtMs = 0; +var cwAutoLocalOverride = null; +function escapeCwHtml(input) { + return cwWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function applyCwAutoUi(enabled) { + if (cwAutoInput) cwAutoInput.checked = enabled; if (cwWpmInput) { - cwWpmInput.addEventListener("change", () => { - void (async () => { - 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); - } - })(); - }); + cwWpmInput.disabled = enabled; + cwWpmInput.readOnly = enabled; } if (cwToneInput) { - cwToneInput.addEventListener("change", () => { - if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value); - }); + cwToneInput.disabled = enabled; + cwToneInput.readOnly = enabled; } - 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); - }); + if (cwTonePickerEl) { + cwTonePickerEl.classList.toggle("is-auto", enabled); } - function resetCwHistoryView() { - if (cwOutputEl) cwOutputEl.innerHTML = ""; - cwLastAppendTime = 0; - cwBarHistory = []; - cwBarCurrentLine = null; - updateCwBar(); - drawCwTonePicker(); +} +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; } - 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 = `
CWLiveLast 15 minutesClear
`; + for (const line of liveLines.slice(0, 8)) { + const ts = line.ts ? `${line.ts}` : ""; + const meta = [ + line.wpm ? `${String(line.wpm)} WPM` : null, + line.tone_hz ? `${String(line.tone_hz)} Hz` : null + ].filter(Boolean).join(" · "); + html += `
${ts}${escapeCwHtml(line.text)}` + (meta ? ` ${escapeCwHtml(meta)}` : "") + `
`; + } + 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 () => { - 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 { - await cwWindow.postPath?.("/clear_cw_decode"); - resetCwHistoryView(); + await cwWindow.postPath?.(`/set_cw_auto?enabled=${enabled ? "true" : "false"}`); + drawCwTonePicker(); } 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 (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); +} +if (cwWpmInput) { + cwWpmInput.addEventListener("change", () => { + void (async () => { + 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); } - 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(); - drawCwTonePicker(); - }; - window.addEventListener("resize", () => { - if (ensureCwToneCanvasResolution()) drawCwTonePicker(); +} +if (cwToneInput) { + cwToneInput.addEventListener("change", () => { + if (!cwAutoInput?.checked) void setCwTone(cwToneInput.value); }); - 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(); + 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(); drawCwTonePicker(); -})(); +}; +window.addEventListener("resize", () => { + if (ensureCwToneCanvasResolution()) drawCwTonePicker(); +}); +applyCwAutoUi(!!cwAutoInput?.checked); +updateCwBar(); +ensureCwToneCanvasResolution(); +drawCwTonePicker(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js index f9f1872b..bb0f2236 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft2.js @@ -1,227 +1,6 @@ -"use strict"; -(() => { - // 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) ? `${grid}` : 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 = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; - 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 ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; - 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 += `
${time}${renderMessage(message.message ?? "")}${detail ? ` · ${detail}` : ""}
`; - } - 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); - } - })(); - }); - } +import { + initializeFtxDecoder +} from "./chunk-SGMG5LG2.js"; - // src/plugins/ft2.ts - initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 }); -})(); +// src/plugins/ft2.ts +initializeFtxDecoder({ id: "ft2", label: "FT2", periodMs: 3750 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js index 0076c658..bafde67f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft4.js @@ -1,227 +1,6 @@ -"use strict"; -(() => { - // 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) ? `${grid}` : 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 = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; - 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 ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; - 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 += `
${time}${renderMessage(message.message ?? "")}${detail ? ` · ${detail}` : ""}
`; - } - 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); - } - })(); - }); - } +import { + initializeFtxDecoder +} from "./chunk-SGMG5LG2.js"; - // src/plugins/ft4.ts - initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 }); -})(); +// src/plugins/ft4.ts +initializeFtxDecoder({ id: "ft4", label: "FT4", periodMs: 7500 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js index 8c782734..d9d961ce 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/ft8.js @@ -1,271 +1,10 @@ -"use strict"; -(() => { - // 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) ? `${grid}` : 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 = `
${label}LiveLast 15 minutesClear
${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 = `${time}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${frequency?.toFixed(0) ?? "--"}${renderMessage(raw)}`; - 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 ? "" : `${bridge.fmtTime?.(timestamp) ?? ""}`; - 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 += `
${time}${renderMessage(message.message ?? "")}${detail ? ` · ${detail}` : ""}
`; - } - 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); - } - })(); - }); - } +import { + initializeFt8FamilyBar, + initializeFtxDecoder, + installFtxCompatibilityHelpers +} from "./chunk-SGMG5LG2.js"; - // src/plugins/ft8.ts - installFtxCompatibilityHelpers(); - initializeFt8FamilyBar(); - initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 }); -})(); +// src/plugins/ft8.ts +installFtxCompatibilityHelpers(); +initializeFt8FamilyBar(); +initializeFtxDecoder({ id: "ft8", label: "FT8", periodMs: 15e3, periodDigits: 0 }); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index 7b9cead2..aa270dfe 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -1,359 +1,265 @@ -"use strict"; -(() => { - // 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)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; - } - function renderAprsCharacter(character) { - const code = character.charCodeAt(0); - return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; - } - 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 `${symbol}`; - } - 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 - }; - } +import { + aprsAgeText, + aprsCategoryLabel, + aprsHexBytes, + aprsPacketCategory, + collapseAprsDuplicates, + normalizeAprsPacket, + renderAprsInfo, + renderLocalAprsSymbol +} from "./chunk-M2I6DH4X.js"; - // src/plugins/hf-aprs.ts - var hfAprsWindow = window; - var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); - var hfAprsStatus = document.getElementById("hf-aprs-status"); - var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); - var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); - var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn"); - var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn"); - var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn"); - var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count"); - var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count"); - var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen"); - var hfAprsFilterText = ""; - var hfAprsPacketHistory = []; - var hfAprsOnlyPos = false; - var hfAprsHideCrc = false; - var hfAprsCollapseDup = false; - var hfAprsTypeFilter = "all"; - function currentHfAprsHistoryRetentionMs() { - return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; - } - function pruneHfAprsPacketHistory() { - const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs(); - hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs); - } - function scheduleHfAprsHistoryRender() { - if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") { - 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 ? `${escapeHfAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const hfBadge = 'HF'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; - const distance = hfAprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - row.innerHTML = `
${ts}` + hfBadge + symbolHtml + `${escapeHfAprsHtml(pkt.srcCall ?? "")}>${escapeHfAprsHtml(pkt.destCall || "")}${escapeHfAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeHfAprsHtml(age)}` + (distance ? `${escapeHfAprsHtml(distance)}` : "") + `${escapeHfAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeHfAprsHtml(pkt.srcCall || "--")}Destination${escapeHfAprsHtml(pkt.destCall || "--")}Type${escapeHfAprsHtml(pkt.type || "--")}Path${escapeHfAprsHtml(pkt.path || "--")}Age${escapeHfAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeHfAprsHtml(pkt.info || "--")}Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; - 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; +// src/plugins/hf-aprs.ts +var hfAprsWindow = window; +var escapeHfAprsHtml = (input) => hfAprsWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var hfAprsStatus = document.getElementById("hf-aprs-status"); +var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); +var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); +var hfAprsOnlyPosBtn = document.getElementById("hf-aprs-only-pos-btn"); +var hfAprsHideCrcBtn = document.getElementById("hf-aprs-hide-crc-btn"); +var hfAprsCollapseDupBtn = document.getElementById("hf-aprs-collapse-dup-btn"); +var hfAprsTotalCountEl = document.getElementById("hf-aprs-total-count"); +var hfAprsVisibleCountEl = document.getElementById("hf-aprs-visible-count"); +var hfAprsLatestSeenEl = document.getElementById("hf-aprs-latest-seen"); +var hfAprsFilterText = ""; +var hfAprsPacketHistory = []; +var hfAprsOnlyPos = false; +var hfAprsHideCrc = false; +var hfAprsCollapseDup = false; +var hfAprsTypeFilter = "all"; +function currentHfAprsHistoryRetentionMs() { + return typeof hfAprsWindow.getDecodeHistoryRetentionMs === "function" ? hfAprsWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneHfAprsPacketHistory() { + const cutoffMs = Date.now() - currentHfAprsHistoryRetentionMs(); + hfAprsPacketHistory = hfAprsPacketHistory.filter((pkt) => (pkt._tsMs ?? 0) >= cutoffMs); +} +function scheduleHfAprsHistoryRender() { + if (typeof hfAprsWindow.trxScheduleUiFrameJob === "function") { + hfAprsWindow.trxScheduleUiFrameJob("hf-aprs-history", () => { 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)); + return; } renderHfAprsHistory(); - window.trxPluginRuntime.registerDecoder({ - id: "hf_aprs", - onMessage: onServerHfAprs, - onBatch: onServerHfAprsBatch, - restore: onServerHfAprsBatch, - reset: resetHfAprsHistoryView, - prune: pruneHfAprsHistoryView +} +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 ? `${escapeHfAprsHtml(pkt.path)}` : ""; + const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; + const hfBadge = 'HF'; + const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); + const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; + const distance = hfAprsDistanceText(pkt); + const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; + row.innerHTML = `
${ts}` + hfBadge + symbolHtml + `${escapeHfAprsHtml(pkt.srcCall ?? "")}>${escapeHfAprsHtml(pkt.destCall || "")}${escapeHfAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeHfAprsHtml(age)}` + (distance ? `${escapeHfAprsHtml(distance)}` : "") + `${escapeHfAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeHfAprsHtml(pkt.srcCall || "--")}Destination${escapeHfAprsHtml(pkt.destCall || "--")}Type${escapeHfAprsHtml(pkt.type || "--")}Path${escapeHfAprsHtml(pkt.path || "--")}Age${escapeHfAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeHfAprsHtml(pkt.info || "--")}Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; + 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 +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js index 32f73063..8ee66d38 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js @@ -1,98 +1,95 @@ -"use strict"; -(() => { - // src/leaflet-ais-tracksymbol.ts - (function() { - const leaflet = globalThis.L; - if (!leaflet) return; - function clamp(value, min, max) { - return Math.max(min, Math.min(max, value)); - } - function finiteAngle(value) { - if (value === null || !Number.isFinite(value)) return null; - const normalized = (value % 360 + 360) % 360; - return normalized; - } - function svgColor(value, fallback) { - const text = value || fallback || ""; - return text.replace(/"/g, """); - } - function buildSymbolHtml(options, zoom) { - const heading = finiteAngle(options.heading); - const course = finiteAngle(options.course); - const angle = heading != null ? heading : course; - const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0; - const sizeBase = Number.isFinite(options.size) ? options.size : 22; +// src/leaflet-ais-tracksymbol.ts +(function() { + const leaflet = globalThis.L; + if (!leaflet) return; + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + function finiteAngle(value) { + if (value === null || !Number.isFinite(value)) return null; + const normalized = (value % 360 + 360) % 360; + return normalized; + } + function svgColor(value, fallback) { + const text = value || fallback || ""; + return text.replace(/"/g, """); + } + function buildSymbolHtml(options, zoom) { + const heading = finiteAngle(options.heading); + const course = finiteAngle(options.course); + const angle = heading != null ? heading : course; + const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0; + const sizeBase = Number.isFinite(options.size) ? options.size : 22; + const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0; + const size = clamp(sizeBase + zoomBoost, 16, 32); + const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0; + const color = svgColor(options.color, "#ff7559"); + const outline = svgColor(options.outline, "#6b2118"); + const body = angle != null ? `` : ``; + const courseLine = course != null ? `` : ""; + return ``; + } + 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 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 ? `` : ``; - const courseLine = course != null ? `` : ""; - return ``; + 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 = 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 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); - }; - })(); + }); + 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); + }; })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index 6c00d230..0f033c1f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,3114 +1,3111 @@ -"use strict"; -(() => { - // src/map-core.ts - function mapEl(id) { - const element = document.querySelector(`#${CSS.escape(id)}`); - if (!element) throw new Error(`Missing map element #${id}`); - return element; +// src/map-core.ts +function mapEl(id) { + const element = document.querySelector(`#${CSS.escape(id)}`); + if (!element) throw new Error(`Missing map element #${id}`); + return element; +} +var mapWindow = window; +(function() { + "use strict"; + const { state: T, core: C, modules } = mapWindow.trx; + const { + saveSetting, + loadSetting, + escapeMapHtml, + formatFreqForHumans, + scheduleUiFrameJob, + latLonToMaidenhead, + locatorToLatLon, + haversineKm, + formatDistanceKm, + formatTimeAgo + } = C; + function updateMapRigFilter() { + const el = mapEl("map-rig-filter"); + if (!el) return; + const prev = el.value; + while (el.options.length > 1) el.remove(1); + for (const id of T.lastRigIds) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = T.lastRigDisplayNames[id] || id; + el.appendChild(opt); + } + if (prev && T.lastRigIds.includes(prev)) { + el.value = prev; + } else { + el.value = ""; + mapRigFilter = ""; + } + updateStatsRigFilter(); } - var mapWindow = window; - (function() { - "use strict"; - const { state: T, core: C, modules } = mapWindow.trx; - const { - saveSetting, - loadSetting, - escapeMapHtml, - formatFreqForHumans, - scheduleUiFrameJob, - latLonToMaidenhead, - locatorToLatLon, - haversineKm, - formatDistanceKm, - formatTimeAgo - } = C; - function updateMapRigFilter() { - const el = mapEl("map-rig-filter"); - if (!el) return; - const prev = el.value; - while (el.options.length > 1) el.remove(1); - for (const id of T.lastRigIds) { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = T.lastRigDisplayNames[id] || id; - el.appendChild(opt); - } - if (prev && T.lastRigIds.includes(prev)) { - el.value = prev; - } else { - el.value = ""; - mapRigFilter = ""; - } - updateStatsRigFilter(); + let aprsMap = null; + let aprsMapBaseLayer = null; + const aprsMapReceiverMarkers = {}; + let aprsRadioPaths = []; + let selectedLocatorMarker = null; + let selectedLocatorPulseRaf = null; + let mapFullscreenListenerBound = false; + let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; + let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; + let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; + const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; + const MAP_QSO_SUMMARY_LIMIT = 5; + const stationMarkers = /* @__PURE__ */ new Map(); + const locatorMarkers = /* @__PURE__ */ new Map(); + const decodeContactPaths = /* @__PURE__ */ new Map(); + let selectedMapQsoKey = null; + const mapMarkers = /* @__PURE__ */ new Set(); + const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; + const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; + const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; + let mapSearchFilter = ""; + let mapRigFilter = ""; + let mapHistoryPruneTimer = null; + let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( + Number(loadSetting("mapHistoryLimitMinutes", 1440)) + ); + const APRS_TRACK_MAX_POINTS = 64; + const AIS_TRACK_MAX_POINTS = 64; + const aisMarkers = /* @__PURE__ */ new Map(); + const vdesMarkers = /* @__PURE__ */ new Map(); + let selectedAprsTrackCall = null; + let selectedAisTrackMmsi = null; + const HAM_BANDS = [ + { label: "2200m", meters: 2200 }, + { label: "630m", meters: 630 }, + { label: "160m", meters: 160 }, + { label: "80m", meters: 80 }, + { label: "60m", meters: 60 }, + { label: "40m", meters: 40 }, + { label: "30m", meters: 30 }, + { label: "20m", meters: 20 }, + { label: "17m", meters: 17 }, + { label: "15m", meters: 15 }, + { label: "12m", meters: 12 }, + { label: "10m", meters: 10 }, + { label: "6m", meters: 6 }, + { label: "4m", meters: 4 }, + { label: "3m", meters: 3 }, + { label: "2m", meters: 2 }, + { label: "1m", meters: 1 }, + { label: "70cm", meters: 0.7 }, + { label: "23cm", meters: 0.23 }, + { label: "13cm", meters: 0.13 }, + { label: "9cm", meters: 0.09 }, + { label: "6cm", meters: 0.06 }, + { label: "3cm", meters: 0.03 } + ].map((band) => ({ + ...band, + nominalHz: 299792458 / band.meters + })); + function normalizeLocatorFreqHz(hz) { + if (!Number.isFinite(hz) || hz <= 0) return null; + if (hz >= 1e5) return hz; + const baseHz = Number(mapWindow.ft8BaseHz); + if (Number.isFinite(baseHz) && baseHz > 0) { + return baseHz + hz; } - let aprsMap = null; - let aprsMapBaseLayer = null; - const aprsMapReceiverMarkers = {}; - let aprsRadioPaths = []; - let selectedLocatorMarker = null; - let selectedLocatorPulseRaf = null; - let mapFullscreenListenerBound = false; - let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; - let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; - let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; - const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440]; - const MAP_QSO_SUMMARY_LIMIT = 5; - const stationMarkers = /* @__PURE__ */ new Map(); - const locatorMarkers = /* @__PURE__ */ new Map(); - const decodeContactPaths = /* @__PURE__ */ new Map(); - let selectedMapQsoKey = null; - const mapMarkers = /* @__PURE__ */ new Set(); - const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark: false, ft8: true, ft4: true, ft2: true, wspr: true, sat: false }; - const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; - const mapLocatorFilter = { phase: "band", bands: /* @__PURE__ */ new Set() }; - let mapSearchFilter = ""; - let mapRigFilter = ""; - let mapHistoryPruneTimer = null; - let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes( - Number(loadSetting("mapHistoryLimitMinutes", 1440)) + return hz; + } + function normalizeMapHistoryLimitMinutes(value) { + const minutes = Math.round(Number(value)); + return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; + } + function mapHistoryCutoffMs() { + return Date.now() - mapHistoryLimitMinutes * 60 * 1e3; + } + function trimTrackHistory(history, cutoffMs, maxPoints) { + const list = Array.isArray(history) ? history : []; + const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); + if (trimmed.length > maxPoints) { + trimmed.splice(0, trimmed.length - maxPoints); + } + return trimmed; + } + function refreshAprsTrack(call, entry) { + if (!entry) return; + if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { + if (entry.track) { + entry.track.remove(); + entry.track = null; + } + return; + } + if (entry.track) { + entry.track.setLatLngs(entry.trackPoints); + return; + } + const track = L.polyline(entry.trackPoints, { + color: "#f0be4d", + weight: 2, + opacity: 0.72, + lineCap: "round", + lineJoin: "round", + interactive: false + }); + track.__trxType = "aprs"; + track._aprsCall = call; + entry.track = track; + } + function refreshAisTrack(mmsi, entry) { + if (!entry) return; + if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { + if (entry.track) { + entry.track.remove(); + entry.track = null; + } + return; + } + if (entry.track) { + entry.track.setLatLngs(entry.trackPoints); + return; + } + const track = L.polyline(entry.trackPoints, { + color: getAisAccentColor(), + weight: 2, + opacity: 0.68, + lineCap: "round", + lineJoin: "round", + interactive: false, + dashArray: "5 4" + }); + track.__trxType = "ais"; + track._aisMmsi = mmsi; + entry.track = track; + } + function setRetainedMapMarkerVisible(marker, visible) { + if (!marker) return; + marker.__trxHistoryVisible = visible; + if (!visible) { + if (marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); + } + } + function ensureAprsMarker(call, entry) { + if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; + _aprsAddMarkerToMap(call, entry); + } + function ensureAisMarker(key, entry) { + if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; + const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg).addTo(aprsMap).bindPopup(buildAisPopupHtml(entry.msg)); + marker.__trxType = "ais"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._aisMmsi = String(key); + entry.marker = marker; + mapMarkers.add(marker); + } + function ensureVdesMarker(key, entry) { + if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; + const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { + radius: 5, + color: "#5c394f", + fillColor: "#c46392", + fillOpacity: 0.82 + }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); + marker.__trxType = "vdes"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._vdesKey = String(key); + entry.marker = marker; + mapMarkers.add(marker); + } + function ensureDecodeLocatorMarker(entry) { + if (!aprsMap || !entry || entry.marker || !entry.grid || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) return; + const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); + const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); + const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)).addTo(aprsMap).bindPopup(tooltipHtml); + marker.__trxType = entry.sourceType; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + sendLocatorOverlayToBack(marker); + assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta); + entry.marker = marker; + mapMarkers.add(marker); + } + function pruneAprsEntry(call, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + const pktTsMs = Number(entry?.pkt?._tsMs); + const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); + if (canRenderMap) { + refreshAprsTrack(call, entry); + } else { + C.markDecodeMapSyncPending(); + } + if (!visible) { + if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) { + selectedAprsTrackCall = null; + } + if (canRenderMap && entry?.track) { + entry.track.remove(); + entry.track = null; + } + if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); + return false; + } + if (!canRenderMap) return true; + ensureAprsMarker(call, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker && entry.lat != null && entry.lon != null) { + entry.marker.setLatLng([entry.lat, entry.lon]); + entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); + } + return true; + } + function pruneAisEntry(key, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + const msgTsMs = Number(entry?.msg?._tsMs); + const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); + if (canRenderMap) { + refreshAisTrack(key, entry); + } else { + C.markDecodeMapSyncPending(); + } + if (!visible) { + if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) { + selectedAisTrackMmsi = null; + } + if (canRenderMap && entry?.track) { + entry.track.remove(); + entry.track = null; + } + if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); + return false; + } + if (!canRenderMap) return true; + ensureAisMarker(key, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker && entry.msg) { + updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); + } + return true; + } + function pruneLocatorEntry(key, entry, cutoffMs) { + const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return true; + if (!(entry.allStationDetails instanceof Map)) { + entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) : /* @__PURE__ */ new Map(); + } + const nextDetails = /* @__PURE__ */ new Map(); + for (const [detailKey, detail] of entry.allStationDetails.entries()) { + const tsMs = Number(detail?.ts_ms); + if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { + nextDetails.set(detailKey, detail); + } + } + entry.visibleInHistoryWindow = nextDetails.size > 0; + if (nextDetails.size === 0) { + entry.stationDetails = /* @__PURE__ */ new Map(); + entry.stations = /* @__PURE__ */ new Set(); + entry.bandMeta = /* @__PURE__ */ new Map(); + if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); + else C.markDecodeMapSyncPending(); + return false; + } + const nextStations = /* @__PURE__ */ new Set(); + for (const detail of nextDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (source) nextStations.add(source); + } + entry.stationDetails = nextDetails; + entry.stations = nextStations; + entry.bandMeta = collectBandMeta( + Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz)) ); - const APRS_TRACK_MAX_POINTS = 64; - const AIS_TRACK_MAX_POINTS = 64; - const aisMarkers = /* @__PURE__ */ new Map(); - const vdesMarkers = /* @__PURE__ */ new Map(); - let selectedAprsTrackCall = null; - let selectedAisTrackMmsi = null; - const HAM_BANDS = [ - { label: "2200m", meters: 2200 }, - { label: "630m", meters: 630 }, - { label: "160m", meters: 160 }, - { label: "80m", meters: 80 }, - { label: "60m", meters: 60 }, - { label: "40m", meters: 40 }, - { label: "30m", meters: 30 }, - { label: "20m", meters: 20 }, - { label: "17m", meters: 17 }, - { label: "15m", meters: 15 }, - { label: "12m", meters: 12 }, - { label: "10m", meters: 10 }, - { label: "6m", meters: 6 }, - { label: "4m", meters: 4 }, - { label: "3m", meters: 3 }, - { label: "2m", meters: 2 }, - { label: "1m", meters: 1 }, - { label: "70cm", meters: 0.7 }, - { label: "23cm", meters: 0.23 }, - { label: "13cm", meters: 0.13 }, - { label: "9cm", meters: 0.09 }, - { label: "6cm", meters: 0.06 }, - { label: "3cm", meters: 0.03 } - ].map((band) => ({ - ...band, - nominalHz: 299792458 / band.meters - })); - function normalizeLocatorFreqHz(hz) { - if (!Number.isFinite(hz) || hz <= 0) return null; - if (hz >= 1e5) return hz; - const baseHz = Number(mapWindow.ft8BaseHz); - if (Number.isFinite(baseHz) && baseHz > 0) { - return baseHz + hz; + const count = Math.max(nextDetails.size, nextStations.size || 0, 1); + if (!canRenderMap) { + C.markDecodeMapSyncPending(); + return true; + } + ensureDecodeLocatorMarker(entry); + setRetainedMapMarkerVisible(entry.marker, true); + if (entry.marker && entry.grid && entry.sourceType) { + entry.marker.setStyle(locatorStyleForEntry(entry, count)); + entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); + assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + } + return true; + } + function pruneMapHistory() { + const cutoffMs = mapHistoryCutoffMs(); + for (const [call, entry] of stationMarkers.entries()) { + pruneAprsEntry(call, entry, cutoffMs); + } + for (const [key, entry] of aisMarkers.entries()) { + pruneAisEntry(key, entry, cutoffMs); + } + for (const [key, entry] of vdesMarkers.entries()) { + const tsMs = Number(entry?.msg?._tsMs); + const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs; + entry.visibleInHistoryWindow = visible; + if (!visible) { + setRetainedMapMarkerVisible(entry?.marker, false); + continue; } - return hz; - } - function normalizeMapHistoryLimitMinutes(value) { - const minutes = Math.round(Number(value)); - return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440; - } - function mapHistoryCutoffMs() { - return Date.now() - mapHistoryLimitMinutes * 60 * 1e3; - } - function trimTrackHistory(history, cutoffMs, maxPoints) { - const list = Array.isArray(history) ? history : []; - const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs); - if (trimmed.length > maxPoints) { - trimmed.splice(0, trimmed.length - maxPoints); + ensureVdesMarker(key, entry); + setRetainedMapMarkerVisible(entry?.marker, true); + if (entry?.marker && entry.msg?.lat != null && entry.msg.lon != null) { + entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); + entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); } - return trimmed; } - function refreshAprsTrack(call, entry) { + for (const [key, entry] of locatorMarkers.entries()) { + pruneLocatorEntry(key, entry, cutoffMs); + } + if (!aprsMap || T.decodeHistoryReplayActive) { + C.markDecodeMapSyncPending(); + return; + } + rebuildDecodeContactPaths(); + rebuildMapLocatorFilters(); + applyMapFilter(); + } + function locatorSourceLabel(type) { + if (type === "bookmark") return "Bookmarks"; + if (type === "wspr") return "WSPR"; + if (type === "ft4") return "FT4"; + if (type === "ft2") return "FT2"; + return "FT8"; + } + function mapSourceLabel(type) { + if (type === "bookmark") return "Bookmarks"; + return String(type || "").toUpperCase(); + } + function locatorFilterColor(type) { + const hues = locatorThemeHues(); + const lightTheme = C.currentTheme() === "light"; + const sat = lightTheme ? 66 : 76; + const light = lightTheme ? 42 : 56; + const hue = type === "bookmark" ? hues.bookmark : type === "wspr" ? hues.wspr : type === "ft4" ? hues.ft4 : type === "ft2" ? hues.ft2 : hues.ft8; + return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; + } + function mapSourceColor(type) { + if (type === "ais") return "#38bdf8"; + if (type === "vdes") return "#a78bfa"; + if (type === "sat") return "#f59e0b"; + if (type === "aprs") return "#00d17f"; + return locatorFilterColor(type); + } + function bandForHz(hz) { + const rfHz = normalizeLocatorFreqHz(hz); + if (rfHz == null || !Number.isFinite(rfHz) || rfHz <= 0) return null; + let bestBand = null; + let bestDistance = Infinity; + for (const band of HAM_BANDS) { + const distance = Math.abs(Math.log(rfHz / band.nominalHz)); + if (distance < bestDistance) { + bestDistance = distance; + bestBand = band; + } + } + return bestBand; + } + function collectBandMeta(freqs) { + const out = /* @__PURE__ */ new Map(); + if (!Array.isArray(freqs)) return out; + for (const hz of freqs) { + const band = bandForHz(hz); + if (band && !out.has(band.label)) out.set(band.label, band.nominalHz); + } + return out; + } + function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { + if (!marker) return; + const safeMeta = bandMeta ?? /* @__PURE__ */ new Map(); + marker._locatorFilterMeta = { + sourceType, + bands: new Set(safeMeta.keys()), + bandMeta: new Map(safeMeta) + }; + } + function parseMapColor(input) { + const value = String(input || "").trim(); + if (!value) return null; + const hex = value.match(/^#([0-9a-f]{3,8})$/i); + if (hex) { + const raw = hex[1]; + if (!raw) return null; + if (raw.length === 3 || raw.length === 4) { + return { + r: parseInt(raw.charAt(0).repeat(2), 16), + g: parseInt(raw.charAt(1).repeat(2), 16), + b: parseInt(raw.charAt(2).repeat(2), 16) + }; + } + if (raw.length === 6 || raw.length === 8) { + return { + r: parseInt(raw.slice(0, 2), 16), + g: parseInt(raw.slice(2, 4), 16), + b: parseInt(raw.slice(4, 6), 16) + }; + } + } + const rgb = value.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i); + if (rgb) { + return { + r: Math.max(0, Math.min(255, Number(rgb[1]))), + g: Math.max(0, Math.min(255, Number(rgb[2]))), + b: Math.max(0, Math.min(255, Number(rgb[3]))) + }; + } + return null; + } + function rgbToHsl(rgb) { + if (!rgb) return null; + const r = rgb.r / 255; + const g = rgb.g / 255; + const b = rgb.b / 255; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const l = (max + min) / 2; + if (max === min) { + return { h: 0, s: 0, l: l * 100 }; + } + const d = max - min; + const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + let h; + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0); + break; + case g: + h = (b - r) / d + 2; + break; + default: + h = (r - g) / d + 4; + break; + } + return { h: h * 60 % 360, s: s * 100, l: l * 100 }; + } + function wrapHue(hue) { + const value = Number(hue) || 0; + return (value % 360 + 360) % 360; + } + function paletteHue(input, fallback) { + const hsl = rgbToHsl(parseMapColor(input)); + return hsl && Number.isFinite(hsl.h) ? hsl.h : fallback; + } + function locatorThemeHues() { + const pal = C.canvasPalette(); + const baseHue = paletteHue(pal?.spectrumLine, 145); + const waveHue = paletteHue(pal?.waveformLine, baseHue + 34); + const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42); + return { + bookmark: wrapHue(baseHue), + ft8: wrapHue(peakHue), + ft4: wrapHue(peakHue + 30), + ft2: wrapHue(peakHue + 60), + wspr: wrapHue((waveHue + baseHue) / 2), + bandBase: wrapHue(baseHue * 0.65 + peakHue * 0.35) + }; + } + function locatorBandIndex(label) { + const idx = HAM_BANDS.findIndex((band) => band.label === label); + return idx >= 0 ? idx : 0; + } + function locatorBandChipColor(label) { + const hues = locatorThemeHues(); + const lightTheme = C.currentTheme() === "light"; + const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); + const sat = lightTheme ? 68 : 78; + const light = lightTheme ? 44 : 58; + return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; + } + function locatorBandLabelForEntry(entry) { + const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); + if (meta.size === 0) return null; + if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { + for (const label of mapLocatorFilter.bands) { + if (meta.has(label)) return label; + } + } + let bestLabel = null; + let bestHz = -Infinity; + for (const [label, hz] of meta.entries()) { + const value = Number.isFinite(hz) ? Number(hz) : 0; + if (value > bestHz) { + bestHz = value; + bestLabel = label; + } + } + return bestLabel; + } + function locatorHueForEntry(entry) { + const hues = locatorThemeHues(); + if (mapLocatorFilter.phase === "band") { + const label = locatorBandLabelForEntry(entry); + if (label) { + return wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); + } + } + if (entry?.sourceType === "bookmark") return hues.bookmark; + if (entry?.sourceType === "wspr") return hues.wspr; + if (entry?.sourceType === "ft4") return hues.ft4; + if (entry?.sourceType === "ft2") return hues.ft2; + return hues.ft8; + } + function locatorStyleForEntry(entry, count) { + const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); + const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); + const hue = locatorHueForEntry(entry); + const lightTheme = C.currentTheme() === "light"; + const strokeSat = lightTheme ? 62 : 74; + const fillSat = lightTheme ? 68 : 78; + const strokeLight = lightTheme ? 40 : 56; + const fillLight = lightTheme ? 60 : 42; + return { + color: `hsl(${hue.toFixed(1)} ${Math.min(92, strokeSat + intensity * 10).toFixed(1)}% ${Math.max(24, strokeLight - intensity * 4).toFixed(1)}%)`, + opacity: 0.42 + intensity * 0.5, + weight: 1 + intensity * 1.2, + fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`, + fillOpacity: 0.16 + intensity * 0.34 + }; + } + function locatorEntryCount(entry) { + if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); + if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); + if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); + return 1; + } + function locatorEntryForMarker(marker) { + if (!marker) return null; + for (const entry of locatorMarkers.values()) { + if (entry?.marker === marker) return entry; + } + return null; + } + function syncLocatorMarkerStyles() { + for (const entry of locatorMarkers.values()) { + if (!entry?.marker) continue; + entry.marker.setStyle(locatorStyleForEntry(entry, locatorEntryCount(entry))); + } + for (const entry of decodeContactPaths.values()) { + if (!entry?.line) continue; + const color = decodeContactPathColor(entry); + entry.line.setStyle({ color, opacity: 0.78 }); + } + } + function stopSelectedLocatorPulse() { + if (selectedLocatorPulseRaf != null) { + cancelAnimationFrame(selectedLocatorPulseRaf); + selectedLocatorPulseRaf = null; + } + } + function startSelectedLocatorPulse(marker) { + stopSelectedLocatorPulse(); + if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; + const tick = (ts) => { + if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) { + return; + } + const entry = locatorEntryForMarker(marker); if (!entry) return; - if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { - if (entry.track) { - entry.track.remove(); - entry.track = null; - } - return; - } - if (entry.track) { - entry.track.setLatLngs(entry.trackPoints); - return; - } - const track = L.polyline(entry.trackPoints, { - color: "#f0be4d", - weight: 2, - opacity: 0.72, - lineCap: "round", - lineJoin: "round", + const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); + const phase = ts % 1600 / 1600; + const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; + marker.setStyle({ + ...base, + opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave), + weight: (base.weight || 1.8) + 1.8 * wave + }); + selectedLocatorPulseRaf = requestAnimationFrame(tick); + }; + selectedLocatorPulseRaf = requestAnimationFrame(tick); + } + function clearMapRadioPath() { + for (const p of aprsRadioPaths) p.remove(); + aprsRadioPaths = []; + } + function clearDecodeContactPathRender(entry) { + if (!entry) return; + if (entry.line) { + entry.line.remove(); + entry.line = null; + } + if (entry.labelMarker) { + entry.labelMarker.remove(); + entry.labelMarker = null; + } + } + function clearDecodeContactPaths() { + for (const entry of decodeContactPaths.values()) { + clearDecodeContactPathRender(entry); + } + decodeContactPaths.clear(); + updateMapPathsAnimationClass(); + } + const MAP_PATHS_STATIC_THRESHOLD = 20; + function updateMapPathsAnimationClass() { + const mapContainer = mapEl("aprs-map"); + mapContainer.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); + } + function formatDecodeContactDistance(distanceKm) { + const text = formatDistanceKm(distanceKm); + return text || "--"; + } + function decodeLocatorPathVisibility(grid) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!normalizedGrid || !aprsMap) return false; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.grid !== normalizedGrid) continue; + if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue; + if (entry.marker && aprsMap.hasLayer(entry.marker)) return true; + } + return false; + } + function midpointLatLon(a, b) { + if (!a || !b) return null; + if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { + return null; + } + return { + lat: (a.lat + b.lat) / 2, + lon: (a.lon + b.lon) / 2 + }; + } + function decodeContactPathColor(entry) { + if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); + const srcEntry = entry.sourceGrid ? locatorMarkers.get(entry.sourceGrid) : void 0; + if (srcEntry) { + const label = locatorBandLabelForEntry(srcEntry); + if (label) return locatorBandChipColor(label); + return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color ?? locatorFilterColor("ft8"); + } + return locatorFilterColor("ft8"); + } + function ensureDecodeContactPathRendered(entry) { + if (!aprsMap || !entry.from || !entry.to) return; + const linePoints = [ + [entry.from.lat, entry.from.lon], + [entry.to.lat, entry.to.lon] + ]; + const color = decodeContactPathColor(entry); + if (!entry.line) { + entry.line = L.polyline(linePoints, { + color, + opacity: 0.78, + className: "decode-contact-path", + weight: 2.8, interactive: false - }); - track.__trxType = "aprs"; - track._aprsCall = call; - entry.track = track; + }).addTo(aprsMap); + } else { + entry.line.setLatLngs(linePoints); + entry.line.setStyle({ color, opacity: 0.78 }); + if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap); } - function refreshAisTrack(mmsi, entry) { - if (!entry) return; - if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) { - if (entry.track) { - entry.track.remove(); - entry.track = null; - } - return; - } - if (entry.track) { - entry.track.setLatLngs(entry.trackPoints); - return; - } - const track = L.polyline(entry.trackPoints, { - color: getAisAccentColor(), - weight: 2, - opacity: 0.68, - lineCap: "round", - lineJoin: "round", + const mid = midpointLatLon(entry.from, entry.to); + if (!mid) return; + const title = `${entry.source} ↔ ${entry.target} · ${entry.distanceText}`; + const icon = L.divIcon({ + className: "decode-contact-distance-label", + html: `${escapeMapHtml(entry.distanceText)}` + }); + if (!entry.labelMarker) { + entry.labelMarker = L.marker([mid.lat, mid.lon], { + icon, interactive: false, - dashArray: "5 4" - }); - track.__trxType = "ais"; - track._aisMmsi = mmsi; - entry.track = track; + keyboard: false, + zIndexOffset: 900 + }).addTo(aprsMap); + } else { + entry.labelMarker.setLatLng([mid.lat, mid.lon]); + entry.labelMarker.setIcon(icon); + if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); } - function setRetainedMapMarkerVisible(marker, visible) { - if (!marker) return; - marker.__trxHistoryVisible = visible; + if (entry.line && typeof entry.line.bringToBack === "function") entry.line.bringToBack(); + } + function decodeContactPathMatchesCurrentMap(entry) { + return !!entry.sourceGrid && !!entry.targetGrid && decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); + } + function decodeContactPathRenderVisible(entry) { + return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); + } + function syncDecodeContactPathVisibility() { + if (selectedMapQsoKey) { + const selectedEntry = decodeContactPaths.get(selectedMapQsoKey); + if (!selectedEntry || !decodeContactPathMatchesCurrentMap(selectedEntry)) { + selectedMapQsoKey = null; + } + } + for (const entry of decodeContactPaths.values()) { + const visible = decodeContactPathRenderVisible(entry) && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); if (!visible) { - if (marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap); + clearDecodeContactPathRender(entry); + continue; } + ensureDecodeContactPathRendered(entry); } - function ensureAprsMarker(call, entry) { - if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return; - _aprsAddMarkerToMap(call, entry); - } - function ensureAisMarker(key, entry) { - if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; - const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg).addTo(aprsMap).bindPopup(buildAisPopupHtml(entry.msg)); - marker.__trxType = "ais"; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._aisMmsi = String(key); - entry.marker = marker; - mapMarkers.add(marker); - } - function ensureVdesMarker(key, entry) { - if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return; - const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], { - radius: 5, - color: "#5c394f", - fillColor: "#c46392", - fillOpacity: 0.82 - }).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg)); - marker.__trxType = "vdes"; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._vdesKey = String(key); - entry.marker = marker; - mapMarkers.add(marker); - } - function ensureDecodeLocatorMarker(entry) { - if (!aprsMap || !entry || entry.marker || !entry.grid || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return; - const bounds = maidenheadToBounds(entry.grid); - if (!bounds) return; - const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1); - const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType); - const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count)).addTo(aprsMap).bindPopup(tooltipHtml); - marker.__trxType = entry.sourceType; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - sendLocatorOverlayToBack(marker); - assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta); - entry.marker = marker; - mapMarkers.add(marker); - } - function pruneAprsEntry(call, entry, cutoffMs) { - const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - const pktTsMs = Number(entry?.pkt?._tsMs); - const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs; - entry.visibleInHistoryWindow = visible; - entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); - if (canRenderMap) { - refreshAprsTrack(call, entry); - } else { - C.markDecodeMapSyncPending(); - } - if (!visible) { - if (canRenderMap && selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) { - selectedAprsTrackCall = null; - } - if (canRenderMap && entry?.track) { - entry.track.remove(); - entry.track = null; - } - if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); - return false; - } - if (!canRenderMap) return true; - ensureAprsMarker(call, entry); - setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker && entry.lat != null && entry.lon != null) { - entry.marker.setLatLng([entry.lat, entry.lon]); - entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt)); - } - return true; - } - function pruneAisEntry(key, entry, cutoffMs) { - const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - const msgTsMs = Number(entry?.msg?._tsMs); - const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs; - entry.visibleInHistoryWindow = visible; - entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS).map((point) => [point.lat, point.lon]); - if (canRenderMap) { - refreshAisTrack(key, entry); - } else { - C.markDecodeMapSyncPending(); - } - if (!visible) { - if (canRenderMap && selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) { - selectedAisTrackMmsi = null; - } - if (canRenderMap && entry?.track) { - entry.track.remove(); - entry.track = null; - } - if (canRenderMap) setRetainedMapMarkerVisible(entry?.marker, false); - return false; - } - if (!canRenderMap) return true; - ensureAisMarker(key, entry); - setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker && entry.msg) { - updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg)); - } - return true; - } - function pruneLocatorEntry(key, entry, cutoffMs) { - const canRenderMap = !!aprsMap && !T.decodeHistoryReplayActive; - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") return true; - if (!(entry.allStationDetails instanceof Map)) { - entry.allStationDetails = entry.stationDetails instanceof Map ? new Map(entry.stationDetails) : /* @__PURE__ */ new Map(); - } - const nextDetails = /* @__PURE__ */ new Map(); - for (const [detailKey, detail] of entry.allStationDetails.entries()) { - const tsMs = Number(detail?.ts_ms); - if (Number.isFinite(tsMs) && tsMs >= cutoffMs) { - nextDetails.set(detailKey, detail); + scheduleStatsRender(); + updateMapPathsAnimationClass(); + } + function _resolveReceiverLocations(rigIds) { + const seen = /* @__PURE__ */ new Set(); + const locations = []; + if (rigIds && rigIds.size) { + for (const rid of rigIds) { + const rig = T.serverRigs.find((r) => r.remote === rid); + if (rig && rig.latitude != null && rig.longitude != null) { + const key = _receiverLocationKey(rig.latitude, rig.longitude); + if (!seen.has(key)) { + seen.add(key); + locations.push([rig.latitude, rig.longitude]); + } } } - entry.visibleInHistoryWindow = nextDetails.size > 0; - if (nextDetails.size === 0) { - entry.stationDetails = /* @__PURE__ */ new Map(); - entry.stations = /* @__PURE__ */ new Set(); - entry.bandMeta = /* @__PURE__ */ new Map(); - if (canRenderMap) setRetainedMapMarkerVisible(entry.marker, false); - else C.markDecodeMapSyncPending(); - return false; - } - const nextStations = /* @__PURE__ */ new Set(); - for (const detail of nextDetails.values()) { - const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (source) nextStations.add(source); - } - entry.stationDetails = nextDetails; - entry.stations = nextStations; - entry.bandMeta = collectBandMeta( - Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz)) + } + if (locations.length === 0 && T.serverLat != null && T.serverLon != null) { + locations.push([T.serverLat, T.serverLon]); + } + return locations; + } + function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { + clearMapRadioPath(); + if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { + return; + } + const sources = _resolveReceiverLocations(rigIds); + for (const src of sources) { + aprsRadioPaths.push( + L.polyline( + [src, [lat, lon]], + { color, opacity: 0.85, weight: 2, interactive: false, className } + ).addTo(aprsMap) ); - const count = Math.max(nextDetails.size, nextStations.size || 0, 1); - if (!canRenderMap) { - C.markDecodeMapSyncPending(); - return true; - } - ensureDecodeLocatorMarker(entry); - setRetainedMapMarkerVisible(entry.marker, true); - if (entry.marker && entry.grid && entry.sourceType) { - entry.marker.setStyle(locatorStyleForEntry(entry, count)); - entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType)); - assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); - } - return true; } - function pruneMapHistory() { - const cutoffMs = mapHistoryCutoffMs(); - for (const [call, entry] of stationMarkers.entries()) { - pruneAprsEntry(call, entry, cutoffMs); - } - for (const [key, entry] of aisMarkers.entries()) { - pruneAisEntry(key, entry, cutoffMs); - } - for (const [key, entry] of vdesMarkers.entries()) { - const tsMs = Number(entry?.msg?._tsMs); - const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs; - entry.visibleInHistoryWindow = visible; - if (!visible) { - setRetainedMapMarkerVisible(entry?.marker, false); - continue; - } - ensureVdesMarker(key, entry); - setRetainedMapMarkerVisible(entry?.marker, true); - if (entry?.marker && entry.msg?.lat != null && entry.msg.lon != null) { - entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]); - entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg)); + } + function locatorMarkerCenter(marker) { + if (!marker) return null; + if (typeof marker.getBounds === "function") { + const bounds = marker.getBounds(); + if (bounds && typeof bounds.getCenter === "function") { + const center = bounds.getCenter(); + if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) { + return { lat: center.lat, lon: center.lng }; } } - for (const [key, entry] of locatorMarkers.entries()) { - pruneLocatorEntry(key, entry, cutoffMs); + } + if (typeof marker.getLatLng === "function") { + const ll = marker.getLatLng(); + if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) { + return { lat: ll.lat, lon: ll.lng }; } - if (!aprsMap || T.decodeHistoryReplayActive) { - C.markDecodeMapSyncPending(); - return; + } + return null; + } + function setLocatorMarkerHighlight(marker, enabled) { + const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; + if (!element) return; + element.classList.toggle("trx-locator-selected", !!enabled); + } + function setSelectedLocatorMarker(marker) { + if (selectedLocatorMarker && selectedLocatorMarker !== marker) { + setLocatorMarkerHighlight(selectedLocatorMarker, false); + const prevEntry = locatorEntryForMarker(selectedLocatorMarker); + if (prevEntry?.marker) { + prevEntry.marker.setStyle(locatorStyleForEntry(prevEntry, locatorEntryCount(prevEntry))); } - rebuildDecodeContactPaths(); + } + stopSelectedLocatorPulse(); + selectedLocatorMarker = marker || null; + if (selectedLocatorMarker) { + setLocatorMarkerHighlight(selectedLocatorMarker, true); + startSelectedLocatorPulse(selectedLocatorMarker); + } + } + function isLocatorOverlay(marker) { + const type = marker?.__trxType; + return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; + } + function sendLocatorOverlayToBack(marker) { + if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; + marker.bringToBack(); + } + function renderMapLocatorChipRow(container, items, selectedSet, kind) { + if (!container) return; + container.replaceChildren(); + if (!Array.isArray(items) || items.length === 0) { + container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available`; + return; + } + let helperText = ""; + const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; + const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); + if (kind === "source") { + if (noneSelected) { + helperText = "All sources visible — click to filter"; + } + } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { + helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; + } + for (const item of items) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-locator-chip"; + const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key); + if (kind === "source" && noneSelected) { + btn.classList.add("is-default"); + } else if (!isActive) { + btn.classList.add("is-inactive"); + } + btn.dataset.filterKind = kind; + btn.dataset.filterKey = item.key; + btn.style.setProperty("--chip-color", item.color); + btn.innerHTML = `${escapeMapHtml(item.label)}`; + container.appendChild(btn); + } + if (helperText) { + const hint = document.createElement("span"); + hint.className = "map-locator-empty"; + hint.textContent = helperText; + container.appendChild(hint); + } + } + function renderMapLocatorPhaseRow(container, phase) { + if (!container) return; + container.replaceChildren(); + const phases = [ + { key: "type", label: "Source" }, + { key: "band", label: "Band" } + ]; + for (const item of phases) { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "map-locator-phase-btn"; + if (phase === item.key) btn.classList.add("is-active"); + btn.dataset.phase = item.key; + btn.textContent = item.label; + container.appendChild(btn); + } + } + function renderMapLocatorLegend(phase, sourceItems, bandItems) { + const legendEl = mapEl("map-band-legend"); + if (!legendEl) return; + const isSourcePhase = phase === "type"; + const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) ? isSourcePhase ? sourceItems : bandItems : []; + if (items.length === 0) { + legendEl.classList.add("is-empty"); + legendEl.replaceChildren(); + return; + } + legendEl.classList.remove("is-empty"); + const rows = items.map((item) => { + const label = escapeMapHtml(item.label); + const color = escapeMapHtml(item.color); + return `${label}`; + }).join(""); + const title = isSourcePhase ? "Source Colors" : "Band Colors"; + legendEl.innerHTML = `
${title}
${rows}
`; + } + mapWindow.enableMapSourceFilter = function(key) { + if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { + mapFilter[key] = true; rebuildMapLocatorFilters(); applyMapFilter(); } - function locatorSourceLabel(type) { - if (type === "bookmark") return "Bookmarks"; - if (type === "wspr") return "WSPR"; - if (type === "ft4") return "FT4"; - if (type === "ft2") return "FT2"; - return "FT8"; - } - function mapSourceLabel(type) { - if (type === "bookmark") return "Bookmarks"; - return String(type || "").toUpperCase(); - } - function locatorFilterColor(type) { - const hues = locatorThemeHues(); - const lightTheme = C.currentTheme() === "light"; - const sat = lightTheme ? 66 : 76; - const light = lightTheme ? 42 : 56; - const hue = type === "bookmark" ? hues.bookmark : type === "wspr" ? hues.wspr : type === "ft4" ? hues.ft4 : type === "ft2" ? hues.ft2 : hues.ft8; - return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; - } - function mapSourceColor(type) { - if (type === "ais") return "#38bdf8"; - if (type === "vdes") return "#a78bfa"; - if (type === "sat") return "#f59e0b"; - if (type === "aprs") return "#00d17f"; - return locatorFilterColor(type); - } - function bandForHz(hz) { - const rfHz = normalizeLocatorFreqHz(hz); - if (rfHz == null || !Number.isFinite(rfHz) || rfHz <= 0) return null; - let bestBand = null; - let bestDistance = Infinity; - for (const band of HAM_BANDS) { - const distance = Math.abs(Math.log(rfHz / band.nominalHz)); - if (distance < bestDistance) { - bestDistance = distance; - bestBand = band; - } + }; + function rebuildMapLocatorFilters() { + const phaseEl = mapEl("map-locator-phase"); + const choiceEl = mapEl("map-locator-choice-filter"); + const choiceLabelEl = mapEl("map-locator-choice-label"); + const availableSources = /* @__PURE__ */ new Set(); + for (const entry of aisMarkers.values()) { + if (entry?.visibleInHistoryWindow) { + availableSources.add("ais"); + break; } - return bestBand; } - function collectBandMeta(freqs) { - const out = /* @__PURE__ */ new Map(); - if (!Array.isArray(freqs)) return out; - for (const hz of freqs) { - const band = bandForHz(hz); - if (band && !out.has(band.label)) out.set(band.label, band.nominalHz); + for (const entry of vdesMarkers.values()) { + if (entry?.visibleInHistoryWindow) { + availableSources.add("vdes"); + break; } - return out; } - function assignLocatorMarkerMeta(marker, sourceType, bandMeta) { - if (!marker) return; - const safeMeta = bandMeta ?? /* @__PURE__ */ new Map(); - marker._locatorFilterMeta = { - sourceType, - bands: new Set(safeMeta.keys()), - bandMeta: new Map(safeMeta) - }; - } - function parseMapColor(input) { - const value = String(input || "").trim(); - if (!value) return null; - const hex = value.match(/^#([0-9a-f]{3,8})$/i); - if (hex) { - const raw = hex[1]; - if (!raw) return null; - if (raw.length === 3 || raw.length === 4) { - return { - r: parseInt(raw.charAt(0).repeat(2), 16), - g: parseInt(raw.charAt(1).repeat(2), 16), - b: parseInt(raw.charAt(2).repeat(2), 16) - }; - } - if (raw.length === 6 || raw.length === 8) { - return { - r: parseInt(raw.slice(0, 2), 16), - g: parseInt(raw.slice(2, 4), 16), - b: parseInt(raw.slice(4, 6), 16) - }; - } + for (const entry of stationMarkers.values()) { + if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { + availableSources.add("aprs"); + break; } - const rgb = value.match(/^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)/i); - if (rgb) { - return { - r: Math.max(0, Math.min(255, Number(rgb[1]))), - g: Math.max(0, Math.min(255, Number(rgb[2]))), - b: Math.max(0, Math.min(255, Number(rgb[3]))) - }; - } - return null; } - function rgbToHsl(rgb) { - if (!rgb) return null; - const r = rgb.r / 255; - const g = rgb.g / 255; - const b = rgb.b / 255; - const max = Math.max(r, g, b); - const min = Math.min(r, g, b); - const l = (max + min) / 2; - if (max === min) { - return { h: 0, s: 0, l: l * 100 }; - } - const d = max - min; - const s = l > 0.5 ? d / (2 - max - min) : d / (max + min); - let h; - switch (max) { - case r: - h = (g - b) / d + (g < b ? 6 : 0); - break; - case g: - h = (b - r) / d + 2; - break; - default: - h = (r - g) / d + 4; - break; - } - return { h: h * 60 % 360, s: s * 100, l: l * 100 }; - } - function wrapHue(hue) { - const value = Number(hue) || 0; - return (value % 360 + 360) % 360; - } - function paletteHue(input, fallback) { - const hsl = rgbToHsl(parseMapColor(input)); - return hsl && Number.isFinite(hsl.h) ? hsl.h : fallback; - } - function locatorThemeHues() { - const pal = C.canvasPalette(); - const baseHue = paletteHue(pal?.spectrumLine, 145); - const waveHue = paletteHue(pal?.waveformLine, baseHue + 34); - const peakHue = paletteHue(pal?.waveformPeak, baseHue - 42); - return { - bookmark: wrapHue(baseHue), - ft8: wrapHue(peakHue), - ft4: wrapHue(peakHue + 30), - ft2: wrapHue(peakHue + 60), - wspr: wrapHue((waveHue + baseHue) / 2), - bandBase: wrapHue(baseHue * 0.65 + peakHue * 0.35) - }; - } - function locatorBandIndex(label) { - const idx = HAM_BANDS.findIndex((band) => band.label === label); - return idx >= 0 ? idx : 0; - } - function locatorBandChipColor(label) { - const hues = locatorThemeHues(); - const lightTheme = C.currentTheme() === "light"; - const hue = wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); - const sat = lightTheme ? 68 : 78; - const light = lightTheme ? 44 : 58; - return `hsl(${hue.toFixed(1)} ${sat}% ${light}%)`; - } - function locatorBandLabelForEntry(entry) { + const bandMap = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + const sourceType = entry?.sourceType; + if (!sourceType) continue; + if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; + availableSources.add(sourceType); const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); - if (meta.size === 0) return null; - if (mapLocatorFilter.phase === "band" && mapLocatorFilter.bands.size > 0) { - for (const label of mapLocatorFilter.bands) { - if (meta.has(label)) return label; - } - } - let bestLabel = null; - let bestHz = -Infinity; for (const [label, hz] of meta.entries()) { - const value = Number.isFinite(hz) ? Number(hz) : 0; - if (value > bestHz) { - bestHz = value; - bestLabel = label; + if (!bandMap.has(label)) { + bandMap.set(label, { + key: label, + label, + color: locatorBandChipColor(label), + kind: "band", + sortHz: Number.isFinite(hz) ? hz : 0 + }); + continue; + } + const existing = bandMap.get(label); + if (existing && Number.isFinite(hz) && (existing.sortHz == null || !Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { + existing.sortHz = hz; + } + if (existing && !existing.color) { + existing.color = locatorBandChipColor(label); } } - return bestLabel; } - function locatorHueForEntry(entry) { - const hues = locatorThemeHues(); - if (mapLocatorFilter.phase === "band") { - const label = locatorBandLabelForEntry(entry); - if (label) { - return wrapHue(hues.bandBase + locatorBandIndex(label) * 137.508); - } - } - if (entry?.sourceType === "bookmark") return hues.bookmark; - if (entry?.sourceType === "wspr") return hues.wspr; - if (entry?.sourceType === "ft4") return hues.ft4; - if (entry?.sourceType === "ft2") return hues.ft2; - return hues.ft8; + for (const key of Array.from(mapLocatorFilter.bands)) { + if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); } - function locatorStyleForEntry(entry, count) { - const safeCount = Math.max(1, Number.isFinite(count) ? count : 1); - const intensity = Math.min(1, Math.log2(safeCount + 1) / 5); - const hue = locatorHueForEntry(entry); - const lightTheme = C.currentTheme() === "light"; - const strokeSat = lightTheme ? 62 : 74; - const fillSat = lightTheme ? 68 : 78; - const strokeLight = lightTheme ? 40 : 56; - const fillLight = lightTheme ? 60 : 42; - return { - color: `hsl(${hue.toFixed(1)} ${Math.min(92, strokeSat + intensity * 10).toFixed(1)}% ${Math.max(24, strokeLight - intensity * 4).toFixed(1)}%)`, - opacity: 0.42 + intensity * 0.5, - weight: 1 + intensity * 1.2, - fillColor: `hsl(${hue.toFixed(1)} ${Math.min(96, fillSat + intensity * 8).toFixed(1)}% ${Math.max(20, fillLight - intensity * 5).toFixed(1)}%)`, - fillOpacity: 0.16 + intensity * 0.34 - }; + const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ + key, + label: mapSourceLabel(key), + color: mapSourceColor(key), + kind: "source" + })); + const bandItems = Array.from(bandMap.values()).sort((a, b) => (b.sortHz ?? 0) - (a.sortHz ?? 0) || a.label.localeCompare(b.label)); + renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); + if (!phaseEl || !choiceEl || !choiceLabelEl) return; + renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); + if (mapLocatorFilter.phase === "band") { + choiceLabelEl.textContent = "Visible Bands"; + renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); + } else { + choiceLabelEl.textContent = "Visible Sources"; + renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); } - function locatorEntryCount(entry) { - if (Array.isArray(entry?.bookmarks)) return Math.max(entry.bookmarks.length, 1); - if (entry?.stationDetails instanceof Map) return Math.max(entry.stationDetails.size, 1); - if (entry?.stations instanceof Set) return Math.max(entry.stations.size, 1); - return 1; - } - function locatorEntryForMarker(marker) { - if (!marker) return null; - for (const entry of locatorMarkers.values()) { - if (entry?.marker === marker) return entry; - } - return null; - } - function syncLocatorMarkerStyles() { - for (const entry of locatorMarkers.values()) { - if (!entry?.marker) continue; - entry.marker.setStyle(locatorStyleForEntry(entry, locatorEntryCount(entry))); - } - for (const entry of decodeContactPaths.values()) { - if (!entry?.line) continue; - const color = decodeContactPathColor(entry); - entry.line.setStyle({ color, opacity: 0.78 }); - } - } - function stopSelectedLocatorPulse() { - if (selectedLocatorPulseRaf != null) { - cancelAnimationFrame(selectedLocatorPulseRaf); - selectedLocatorPulseRaf = null; - } - } - function startSelectedLocatorPulse(marker) { - stopSelectedLocatorPulse(); - if (!marker || !aprsMap || !aprsMap.hasLayer(marker)) return; - const tick = (ts) => { - if (!selectedLocatorMarker || selectedLocatorMarker !== marker || !aprsMap || !aprsMap.hasLayer(marker)) { - return; - } - const entry = locatorEntryForMarker(marker); - if (!entry) return; - const base = locatorStyleForEntry(entry, locatorEntryCount(entry)); - const phase = ts % 1600 / 1600; - const wave = (Math.sin(phase * Math.PI * 2 - Math.PI / 2) + 1) / 2; - marker.setStyle({ - ...base, - opacity: Math.min(1, (base.opacity || 0.8) + 0.12 * wave), - weight: (base.weight || 1.8) + 1.8 * wave - }); - selectedLocatorPulseRaf = requestAnimationFrame(tick); - }; - selectedLocatorPulseRaf = requestAnimationFrame(tick); - } - function clearMapRadioPath() { - for (const p of aprsRadioPaths) p.remove(); - aprsRadioPaths = []; - } - function clearDecodeContactPathRender(entry) { - if (!entry) return; - if (entry.line) { - entry.line.remove(); - entry.line = null; - } - if (entry.labelMarker) { - entry.labelMarker.remove(); - entry.labelMarker = null; - } - } - function clearDecodeContactPaths() { - for (const entry of decodeContactPaths.values()) { - clearDecodeContactPathRender(entry); - } - decodeContactPaths.clear(); - updateMapPathsAnimationClass(); - } - const MAP_PATHS_STATIC_THRESHOLD = 20; - function updateMapPathsAnimationClass() { - const mapContainer = mapEl("aprs-map"); - mapContainer.classList.toggle("map-paths-static", decodeContactPaths.size > MAP_PATHS_STATIC_THRESHOLD); - } - function formatDecodeContactDistance(distanceKm) { - const text = formatDistanceKm(distanceKm); - return text || "--"; - } - function decodeLocatorPathVisibility(grid) { - const normalizedGrid = String(grid || "").trim().toUpperCase(); - if (!normalizedGrid || !aprsMap) return false; - for (const entry of locatorMarkers.values()) { - if (!entry || entry.grid !== normalizedGrid) continue; - if (entry.sourceType !== "ft8" && entry.sourceType !== "wspr") continue; - if (entry.marker && aprsMap.hasLayer(entry.marker)) return true; + syncLocatorMarkerStyles(); + syncDecodeContactPathVisibility(); + } + function markerPassesLocatorFilters(marker) { + const meta = marker?._locatorFilterMeta; + if (!meta) return true; + if (mapLocatorFilter.phase === "band") { + if (mapLocatorFilter.bands.size === 0) return true; + if (!(meta.bands instanceof Set)) return false; + for (const label of mapLocatorFilter.bands) { + if (meta.bands.has(label)) return true; } return false; } - function midpointLatLon(a, b) { - if (!a || !b) return null; - if (!Number.isFinite(a.lat) || !Number.isFinite(a.lon) || !Number.isFinite(b.lat) || !Number.isFinite(b.lon)) { - return null; - } - return { - lat: (a.lat + b.lat) / 2, - lon: (a.lon + b.lon) / 2 - }; - } - function decodeContactPathColor(entry) { - if (entry?.bandLabel) return locatorBandChipColor(entry.bandLabel); - const srcEntry = entry.sourceGrid ? locatorMarkers.get(entry.sourceGrid) : void 0; - if (srcEntry) { - const label = locatorBandLabelForEntry(srcEntry); - if (label) return locatorBandChipColor(label); - return locatorStyleForEntry(srcEntry, locatorEntryCount(srcEntry)).color ?? locatorFilterColor("ft8"); - } - return locatorFilterColor("ft8"); - } - function ensureDecodeContactPathRendered(entry) { - if (!aprsMap || !entry.from || !entry.to) return; - const linePoints = [ - [entry.from.lat, entry.from.lon], - [entry.to.lat, entry.to.lon] - ]; - const color = decodeContactPathColor(entry); - if (!entry.line) { - entry.line = L.polyline(linePoints, { - color, - opacity: 0.78, - className: "decode-contact-path", - weight: 2.8, - interactive: false - }).addTo(aprsMap); - } else { - entry.line.setLatLngs(linePoints); - entry.line.setStyle({ color, opacity: 0.78 }); - if (!aprsMap.hasLayer(entry.line)) entry.line.addTo(aprsMap); - } - const mid = midpointLatLon(entry.from, entry.to); - if (!mid) return; - const title = `${entry.source} ↔ ${entry.target} · ${entry.distanceText}`; - const icon = L.divIcon({ - className: "decode-contact-distance-label", - html: `${escapeMapHtml(entry.distanceText)}` - }); - if (!entry.labelMarker) { - entry.labelMarker = L.marker([mid.lat, mid.lon], { - icon, - interactive: false, - keyboard: false, - zIndexOffset: 900 - }).addTo(aprsMap); - } else { - entry.labelMarker.setLatLng([mid.lat, mid.lon]); - entry.labelMarker.setIcon(icon); - if (!aprsMap.hasLayer(entry.labelMarker)) entry.labelMarker.addTo(aprsMap); - } - if (entry.line && typeof entry.line.bringToBack === "function") entry.line.bringToBack(); - } - function decodeContactPathMatchesCurrentMap(entry) { - return !!entry.sourceGrid && !!entry.targetGrid && decodeLocatorPathVisibility(entry.sourceGrid) && decodeLocatorPathVisibility(entry.targetGrid); - } - function decodeContactPathRenderVisible(entry) { - return mapDecodeContactPathsEnabled && decodeContactPathMatchesCurrentMap(entry); - } - function syncDecodeContactPathVisibility() { - if (selectedMapQsoKey) { - const selectedEntry = decodeContactPaths.get(selectedMapQsoKey); - if (!selectedEntry || !decodeContactPathMatchesCurrentMap(selectedEntry)) { - selectedMapQsoKey = null; + return true; + } + function markerSearchText(marker) { + const type = marker?.__trxType; + if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { + const entry = locatorEntryForMarker(marker); + const parts = []; + if (entry?.grid) parts.push(entry.grid); + if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType)); + if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys())); + if (Array.isArray(entry?.bookmarks)) { + for (const bm of entry.bookmarks) { + if (bm?.name) parts.push(String(bm.name)); + if (bm?.locator) parts.push(String(bm.locator)); + if (bm?.mode) parts.push(String(bm.mode)); + if (bm?.category) parts.push(String(bm.category)); + if (bm?.comment) parts.push(String(bm.comment)); + if (Number.isFinite(bm?.freq_hz)) parts.push(String(Math.round(Number(bm.freq_hz)))); } } - for (const entry of decodeContactPaths.values()) { - const visible = decodeContactPathRenderVisible(entry) && (!selectedMapQsoKey || entry.pathKey === selectedMapQsoKey); - if (!visible) { - clearDecodeContactPathRender(entry); - continue; - } - ensureDecodeContactPathRendered(entry); + if (entry?.stations instanceof Set) { + parts.push(...Array.from(entry.stations.values()).map((v) => String(v))); } - scheduleStatsRender(); - updateMapPathsAnimationClass(); - } - function _resolveReceiverLocations(rigIds) { - const seen = /* @__PURE__ */ new Set(); - const locations = []; - if (rigIds && rigIds.size) { - for (const rid of rigIds) { - const rig = T.serverRigs.find((r) => r.remote === rid); - if (rig && rig.latitude != null && rig.longitude != null) { - const key = _receiverLocationKey(rig.latitude, rig.longitude); - if (!seen.has(key)) { - seen.add(key); - locations.push([rig.latitude, rig.longitude]); - } - } + if (entry?.stationDetails instanceof Map) { + for (const detail of entry.stationDetails.values()) { + if (detail?.station) parts.push(String(detail.station)); + if (detail?.message) parts.push(String(detail.message)); + if (Number.isFinite(detail?.freq_hz)) parts.push(String(Math.round(Number(detail.freq_hz)))); } } - if (locations.length === 0 && T.serverLat != null && T.serverLon != null) { - locations.push([T.serverLat, T.serverLon]); - } - return locations; + return parts.join(" ").toLowerCase(); } - function setMapRadioPathTo(lat, lon, color, className = "aprs-radio-path", rigIds) { - clearMapRadioPath(); - if (!mapP2pRadioPathsEnabled || !Number.isFinite(lat) || !Number.isFinite(lon) || !aprsMap) { - return; - } - const sources = _resolveReceiverLocations(rigIds); - for (const src of sources) { - aprsRadioPaths.push( - L.polyline( - [src, [lat, lon]], - { color, opacity: 0.85, weight: 2, interactive: false, className } - ).addTo(aprsMap) - ); - } + if (type === "aprs") { + const call = marker?._aprsCall ? String(marker._aprsCall) : ""; + const entry = stationMarkers.get(call); + const info = entry?.info ? String(entry.info) : ""; + const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; + return `${call} ${info} ${pktRaw}`.toLowerCase(); } - function locatorMarkerCenter(marker) { - if (!marker) return null; - if (typeof marker.getBounds === "function") { - const bounds = marker.getBounds(); - if (bounds && typeof bounds.getCenter === "function") { - const center = bounds.getCenter(); - if (Number.isFinite(center?.lat) && Number.isFinite(center?.lng)) { - return { lat: center.lat, lon: center.lng }; - } - } - } - if (typeof marker.getLatLng === "function") { - const ll = marker.getLatLng(); - if (Number.isFinite(ll?.lat) && Number.isFinite(ll?.lng)) { - return { lat: ll.lat, lon: ll.lng }; - } - } - return null; - } - function setLocatorMarkerHighlight(marker, enabled) { - const element = typeof marker?.getElement === "function" ? marker.getElement() : marker?._path; - if (!element) return; - element.classList.toggle("trx-locator-selected", !!enabled); - } - function setSelectedLocatorMarker(marker) { - if (selectedLocatorMarker && selectedLocatorMarker !== marker) { - setLocatorMarkerHighlight(selectedLocatorMarker, false); - const prevEntry = locatorEntryForMarker(selectedLocatorMarker); - if (prevEntry?.marker) { - prevEntry.marker.setStyle(locatorStyleForEntry(prevEntry, locatorEntryCount(prevEntry))); - } - } - stopSelectedLocatorPulse(); - selectedLocatorMarker = marker || null; - if (selectedLocatorMarker) { - setLocatorMarkerHighlight(selectedLocatorMarker, true); - startSelectedLocatorPulse(selectedLocatorMarker); - } - } - function isLocatorOverlay(marker) { - const type = marker?.__trxType; - return type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr"; - } - function sendLocatorOverlayToBack(marker) { - if (!isLocatorOverlay(marker) || typeof marker?.bringToBack !== "function") return; - marker.bringToBack(); - } - function renderMapLocatorChipRow(container, items, selectedSet, kind) { - if (!container) return; - container.replaceChildren(); - if (!Array.isArray(items) || items.length === 0) { - container.innerHTML = `No ${kind === "band" ? "bands" : "sources"} available`; - return; - } - let helperText = ""; - const sourceKeys = kind === "source" ? Object.keys(DEFAULT_MAP_SOURCE_FILTER) : []; - const noneSelected = kind === "source" && sourceKeys.every((k) => !mapFilter[k]); - if (kind === "source") { - if (noneSelected) { - helperText = "All sources visible — click to filter"; - } - } else if (!(selectedSet instanceof Set) || selectedSet.size === 0) { - helperText = `All ${kind === "band" ? "bands" : "sources"} visible by default`; - } - for (const item of items) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "map-locator-chip"; - const isActive = kind === "source" ? !!mapFilter[item.key] : !!selectedSet?.has(item.key); - if (kind === "source" && noneSelected) { - btn.classList.add("is-default"); - } else if (!isActive) { - btn.classList.add("is-inactive"); - } - btn.dataset.filterKind = kind; - btn.dataset.filterKey = item.key; - btn.style.setProperty("--chip-color", item.color); - btn.innerHTML = `${escapeMapHtml(item.label)}`; - container.appendChild(btn); - } - if (helperText) { - const hint = document.createElement("span"); - hint.className = "map-locator-empty"; - hint.textContent = helperText; - container.appendChild(hint); - } - } - function renderMapLocatorPhaseRow(container, phase) { - if (!container) return; - container.replaceChildren(); - const phases = [ - { key: "type", label: "Source" }, - { key: "band", label: "Band" } - ]; - for (const item of phases) { - const btn = document.createElement("button"); - btn.type = "button"; - btn.className = "map-locator-phase-btn"; - if (phase === item.key) btn.classList.add("is-active"); - btn.dataset.phase = item.key; - btn.textContent = item.label; - container.appendChild(btn); - } - } - function renderMapLocatorLegend(phase, sourceItems, bandItems) { - const legendEl = mapEl("map-band-legend"); - if (!legendEl) return; - const isSourcePhase = phase === "type"; - const items = Array.isArray(isSourcePhase ? sourceItems : bandItems) ? isSourcePhase ? sourceItems : bandItems : []; - if (items.length === 0) { - legendEl.classList.add("is-empty"); - legendEl.replaceChildren(); - return; - } - legendEl.classList.remove("is-empty"); - const rows = items.map((item) => { - const label = escapeMapHtml(item.label); - const color = escapeMapHtml(item.color); - return `${label}`; - }).join(""); - const title = isSourcePhase ? "Source Colors" : "Band Colors"; - legendEl.innerHTML = `
${title}
${rows}
`; - } - mapWindow.enableMapSourceFilter = function(key) { - if (Object.prototype.hasOwnProperty.call(mapFilter, key) && !mapFilter[key]) { - mapFilter[key] = true; - rebuildMapLocatorFilters(); - applyMapFilter(); - } - }; - function rebuildMapLocatorFilters() { - const phaseEl = mapEl("map-locator-phase"); - const choiceEl = mapEl("map-locator-choice-filter"); - const choiceLabelEl = mapEl("map-locator-choice-label"); - const availableSources = /* @__PURE__ */ new Set(); - for (const entry of aisMarkers.values()) { - if (entry?.visibleInHistoryWindow) { - availableSources.add("ais"); - break; - } - } - for (const entry of vdesMarkers.values()) { - if (entry?.visibleInHistoryWindow) { - availableSources.add("vdes"); - break; - } - } - for (const entry of stationMarkers.values()) { - if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) { - availableSources.add("aprs"); - break; - } - } - const bandMap = /* @__PURE__ */ new Map(); - for (const entry of locatorMarkers.values()) { - const sourceType = entry?.sourceType; - if (!sourceType) continue; - if ((sourceType === "ft8" || sourceType === "ft4" || sourceType === "ft2" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue; - availableSources.add(sourceType); - const meta = entry?.bandMeta ?? /* @__PURE__ */ new Map(); - for (const [label, hz] of meta.entries()) { - if (!bandMap.has(label)) { - bandMap.set(label, { - key: label, - label, - color: locatorBandChipColor(label), - kind: "band", - sortHz: Number.isFinite(hz) ? hz : 0 - }); - continue; - } - const existing = bandMap.get(label); - if (existing && Number.isFinite(hz) && (existing.sortHz == null || !Number.isFinite(existing.sortHz) || hz > existing.sortHz)) { - existing.sortHz = hz; - } - if (existing && !existing.color) { - existing.color = locatorBandChipColor(label); - } - } - } - for (const key of Array.from(mapLocatorFilter.bands)) { - if (!bandMap.has(key)) mapLocatorFilter.bands.delete(key); - } - const sourceItems = ["ais", "vdes", "aprs", "bookmark", "ft8", "ft4", "ft2", "wspr"].filter((key) => availableSources.has(key)).map((key) => ({ + if (type === "ais") { + const key = marker?._aisMmsi ? String(marker._aisMmsi) : ""; + const msg = aisMarkers.get(key)?.msg; + return [ key, - label: mapSourceLabel(key), - color: mapSourceColor(key), - kind: "source" - })); - const bandItems = Array.from(bandMap.values()).sort((a, b) => (b.sortHz ?? 0) - (a.sortHz ?? 0) || a.label.localeCompare(b.label)); - renderMapLocatorLegend(mapLocatorFilter.phase, sourceItems, bandItems); - if (!phaseEl || !choiceEl || !choiceLabelEl) return; - renderMapLocatorPhaseRow(phaseEl, mapLocatorFilter.phase); - if (mapLocatorFilter.phase === "band") { - choiceLabelEl.textContent = "Visible Bands"; - renderMapLocatorChipRow(choiceEl, bandItems, mapLocatorFilter.bands, "band"); - } else { - choiceLabelEl.textContent = "Visible Sources"; - renderMapLocatorChipRow(choiceEl, sourceItems, null, "source"); - } - syncLocatorMarkerStyles(); - syncDecodeContactPathVisibility(); - } - function markerPassesLocatorFilters(marker) { - const meta = marker?._locatorFilterMeta; - if (!meta) return true; - if (mapLocatorFilter.phase === "band") { - if (mapLocatorFilter.bands.size === 0) return true; - if (!(meta.bands instanceof Set)) return false; - for (const label of mapLocatorFilter.bands) { - if (meta.bands.has(label)) return true; - } - return false; - } - return true; - } - function markerSearchText(marker) { - const type = marker?.__trxType; - if (type === "bookmark" || type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { - const entry = locatorEntryForMarker(marker); - const parts = []; - if (entry?.grid) parts.push(entry.grid); - if (entry?.sourceType) parts.push(locatorSourceLabel(entry.sourceType)); - if (entry?.bandMeta instanceof Map) parts.push(...Array.from(entry.bandMeta.keys())); - if (Array.isArray(entry?.bookmarks)) { - for (const bm of entry.bookmarks) { - if (bm?.name) parts.push(String(bm.name)); - if (bm?.locator) parts.push(String(bm.locator)); - if (bm?.mode) parts.push(String(bm.mode)); - if (bm?.category) parts.push(String(bm.category)); - if (bm?.comment) parts.push(String(bm.comment)); - if (Number.isFinite(bm?.freq_hz)) parts.push(String(Math.round(Number(bm.freq_hz)))); - } - } - if (entry?.stations instanceof Set) { - parts.push(...Array.from(entry.stations.values()).map((v) => String(v))); - } - if (entry?.stationDetails instanceof Map) { - for (const detail of entry.stationDetails.values()) { - if (detail?.station) parts.push(String(detail.station)); - if (detail?.message) parts.push(String(detail.message)); - if (Number.isFinite(detail?.freq_hz)) parts.push(String(Math.round(Number(detail.freq_hz)))); - } - } - return parts.join(" ").toLowerCase(); - } - if (type === "aprs") { - const call = marker?._aprsCall ? String(marker._aprsCall) : ""; - const entry = stationMarkers.get(call); - const info = entry?.info ? String(entry.info) : ""; - const pktRaw = entry?.pkt?.raw ? String(entry.pkt.raw) : ""; - return `${call} ${info} ${pktRaw}`.toLowerCase(); - } - if (type === "ais") { - const key = marker?._aisMmsi ? String(marker._aisMmsi) : ""; - const msg = aisMarkers.get(key)?.msg; - return [ - key, - msg?.name, - msg?.callsign, - msg?.destination, - msg && Number.isFinite(msg.mmsi) ? String(msg.mmsi) : "", - msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", - msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" - ].join(" ").toLowerCase(); - } - if (type === "vdes") { - const key = marker?._vdesKey ? String(marker._vdesKey) : ""; - const msg = vdesMarkers.get(key)?.msg; - return [ - key, - msg?.name, - msg?.mmsi, - msg?.message, - msg?.raw, - msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", - msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" - ].join(" ").toLowerCase(); - } - return ""; - } - function markerPassesSearchFilter(marker) { - const query = String(mapSearchFilter || "").trim().toLowerCase(); - if (!query) return true; - const terms = query.split(/\s+/).filter(Boolean); - if (terms.length === 0) return true; - const haystack = markerSearchText(marker); - if (!haystack) return false; - return terms.every((term) => haystack.includes(term)); - } - function _receiverLocationKey(lat, lon) { - return lat.toFixed(6) + "," + lon.toFixed(6); - } - function syncAprsReceiverMarker() { - if (!aprsMap) return; - const locGroups = {}; - const activeId = T.lastActiveRigId || T.serverActiveRigId || null; - for (const rig of T.serverRigs) { - if (!rig || !rig.remote) continue; - const lat = rig.latitude, lon = rig.longitude; - if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; - const key = _receiverLocationKey(lat, lon); - const group = locGroups[key] ??= { lat, lon, rigs: /* @__PURE__ */ new Set(), hasActive: false }; - group.rigs.add(rig.remote); - if (rig.remote === activeId) group.hasActive = true; - } - if (T.serverLat != null && T.serverLon != null) { - const key = _receiverLocationKey(T.serverLat, T.serverLon); - const group = locGroups[key] ??= { lat: T.serverLat, lon: T.serverLon, rigs: /* @__PURE__ */ new Set(), hasActive: true }; - group.hasActive = true; - } - const seen = /* @__PURE__ */ new Set(); - let didInitialView = false; - for (const [key, group] of Object.entries(locGroups)) { - seen.add(key); - const latLng = [group.lat, group.lon]; - const isActive = group.hasActive; - let m = aprsMapReceiverMarkers[key]; - if (!m) { - m = L.circleMarker(latLng, { - radius: isActive ? 8 : 6, - className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), - fillOpacity: isActive ? 0.8 : 0.6 - }).addTo(aprsMap).bindPopup(""); - m._receiverLocKey = key; - m._receiverRigs = group.rigs; - aprsMapReceiverMarkers[key] = m; - if (isActive && !didInitialView) { - aprsMap.setView(latLng, Math.max(1, T.initialMapZoom)); - didInitialView = true; - } - } else { - m.setLatLng(latLng); - m._receiverRigs = group.rigs; - m.setRadius(isActive ? 8 : 6); - if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); - } - } - for (const key of Object.keys(aprsMapReceiverMarkers)) { - if (!seen.has(key)) { - const m = aprsMapReceiverMarkers[key]; - if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); - Reflect.deleteProperty(aprsMapReceiverMarkers, key); - } - } - } - const satOverlays = /* @__PURE__ */ new Map(); - let satOverlaySeq = 0; - mapWindow.addSatMapOverlay = function(msg) { - if (!msg || !Array.isArray(msg.geo_bounds) || typeof msg.path !== "string") return; - const bounds = msg.geo_bounds; - if (!Array.isArray(bounds) || bounds.length !== 4) return; - const latLngBounds = L.latLngBounds( - [Number(bounds[0]), Number(bounds[1])], - // SW - [Number(bounds[2]), Number(bounds[3])] - // NE - ); - const key = `sat-${++satOverlaySeq}`; - const overlay = L.imageOverlay(msg.path, latLngBounds, { - opacity: 0.55, - interactive: true, - zIndex: 300 - }); - overlay.__trxType = "sat"; - overlay.__trxSatKey = key; - overlay.__trxRigIds = msg.rig_id ? /* @__PURE__ */ new Set([msg.rig_id]) : /* @__PURE__ */ new Set(); - overlay.__trxHistoryVisible = true; - mapMarkers.add(overlay); - const decoder = "Meteor LRPT"; - const satellite = msg.satellite || "Unknown"; - const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : ""; - overlay.bindPopup( - `
${escapeMapHtml(decoder)}
${escapeMapHtml(satellite)}
${escapeMapHtml(ts)}
` + (msg.path ? `Download PNG` : "") + `
` - ); - let track = null; - if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { - const latlngs = msg.ground_track.filter((pt) => Array.isArray(pt) && pt.length >= 2).map((pt) => [Number(pt[0]), Number(pt[1])]); - track = L.polyline(latlngs, { - color: mapSourceColor("sat"), - weight: 2, - opacity: 0.7, - dashArray: "6, 4" - }); - track.__trxType = "sat"; - track.__trxSatKey = key; - track.__trxRigIds = overlay.__trxRigIds ?? /* @__PURE__ */ new Set(); - track.__trxHistoryVisible = true; - mapMarkers.add(track); - if (aprsMap) { - track.addTo(aprsMap); - } - } - satOverlays.set(key, { overlay, track, msg }); - if (aprsMap) { - overlay.addTo(aprsMap); - } - applyMapFilter(); - }; - mapWindow.removeSatMapOverlay = function(key) { - const entry = satOverlays.get(key); - if (!entry) return; - if (entry.overlay) { - mapMarkers.delete(entry.overlay); - if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap); - } - if (entry.track) { - mapMarkers.delete(entry.track); - if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); - } - satOverlays.delete(key); - }; - mapWindow.clearSatMapOverlays = function() { - for (const [key] of satOverlays) { - mapWindow.removeSatMapOverlay?.(key); - } - }; - mapWindow.clearMapMarkersByType = function(type) { - if (type === "aprs") { - selectedAprsTrackCall = null; - stationMarkers.forEach((entry) => { - if (entry && entry.marker) { - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - if (entry && entry.track) { - if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); - mapMarkers.delete(entry.track); - } - }); - stationMarkers.clear(); - return; - } - if (type === "ais") { - aisMarkers.forEach((entry) => { - if (entry && entry.marker) { - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - if (entry && entry.track) { - if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); - mapMarkers.delete(entry.track); - } - }); - selectedAisTrackMmsi = null; - aisMarkers.clear(); - return; - } - if (type === "vdes") { - vdesMarkers.forEach((entry) => { - if (entry && entry.marker) { - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - }); - vdesMarkers.clear(); - return; - } - if (type === "sat") { - mapWindow.clearSatMapOverlays?.(); - return; - } - if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { - const prefix = `${type}:`; - for (const [key, entry] of locatorMarkers.entries()) { - if (!key.startsWith(prefix)) continue; - if (entry && entry.marker) { - if (entry.marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - locatorMarkers.delete(key); - } - rebuildMapLocatorFilters(); - rebuildDecodeContactPaths(); - } - if (type === "bookmark") { - for (const [key, entry] of locatorMarkers.entries()) { - if (!key.startsWith("bookmark:")) continue; - if (entry && entry.marker) { - if (entry.marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); - } - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); - } - locatorMarkers.delete(key); - } - rebuildMapLocatorFilters(); - } - }; - function mapTileSpecForTheme(theme) { - if (theme === "dark") { - return { - url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", - options: { - maxZoom: 19, - subdomains: "abcd", - attribution: '© OpenStreetMap © CARTO' - } - }; - } - return { - url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", - options: { - maxZoom: 19, - attribution: '© OpenStreetMap' - } - }; - } - function updateMapBaseLayerForTheme(theme) { - if (!aprsMap) return; - if (aprsMapBaseLayer) { - aprsMap.removeLayer(aprsMapBaseLayer); - aprsMapBaseLayer = null; - } - const spec = mapTileSpecForTheme(theme); - aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); - } - function mapStageEl() { - return mapEl("map-stage"); - } - function mapIsFullscreen() { - const stage = mapStageEl(); - if (!stage) return false; - return document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); - } - function mapExitFakeFullscreen() { - const stage = mapStageEl(); - if (!stage) return; - stage.classList.remove("map-fake-fullscreen"); - document.body.classList.remove("map-fake-fullscreen-active"); - } - function mapEnterFakeFullscreen() { - const stage = mapStageEl(); - if (!stage) return; - stage.classList.add("map-fake-fullscreen"); - document.body.classList.add("map-fake-fullscreen-active"); - } - function updateMapFullscreenButton() { - const btn = mapEl("map-fullscreen-btn"); - if (!btn) return; - btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; - } - function applyMapOverlayPanelVisibility() { - const panel = document.querySelector("#map-stage .map-overlay-panel"); - if (!panel) return; - panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); - } - function updateMapOverlayToggleButton() { - const btn = mapEl("map-overlay-toggle-btn"); - if (!btn) return; - btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; - } - async function toggleMapFullscreen() { - const stage = mapStageEl(); - if (!stage) return; - try { - const webkitDocument = document; - const isNative = document.fullscreenElement === stage || webkitDocument.webkitFullscreenElement === stage; - const isFake = stage.classList.contains("map-fake-fullscreen"); - if (isNative) { - if (document.exitFullscreen) await document.exitFullscreen(); - else if (webkitDocument.webkitExitFullscreen) await webkitDocument.webkitExitFullscreen(); - } else if (isFake) { - mapExitFakeFullscreen(); - } else { - const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; - if (nativeFn) { - try { - await nativeFn.call(stage); - } catch { - mapEnterFakeFullscreen(); - } - } else { - mapEnterFakeFullscreen(); - } - } - } catch (err) { - console.error("Map fullscreen toggle failed", err); - } finally { - updateMapFullscreenButton(); - requestAnimationFrame(() => { - sizeAprsMapToViewport(); - }); - } - } - document.addEventListener("keydown", (e) => { - if (e.key === "Escape") { - const stage = mapStageEl(); - if (stage && stage.classList.contains("map-fake-fullscreen")) { - mapExitFakeFullscreen(); - updateMapFullscreenButton(); - requestAnimationFrame(() => { - sizeAprsMapToViewport(); - }); - } - } - }); - function materializeBufferedMapLayers() { - if (!aprsMap) return; - for (const [key, entry] of locatorMarkers) { - if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue; - const bounds = maidenheadToBounds(entry.grid); - if (!bounds) continue; - entry.sourceType = "bookmark"; - entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); - entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)).addTo(aprsMap).bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); - entry.marker.__trxType = "bookmark"; - sendLocatorOverlayToBack(entry.marker); - assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); - mapMarkers.add(entry.marker); - } - pruneMapHistory(); - } - let stageResizeObserver = null; - function initAprsMap() { - if (typeof L === "undefined") return; - mapEl("aprs-map"); - sizeAprsMapToViewport(); - if (aprsMap) return; - const hasLocation = T.serverLat != null && T.serverLon != null; - const center = hasLocation && T.serverLat != null && T.serverLon != null ? [T.serverLat, T.serverLon] : [20, 0]; - const zoom = hasLocation ? T.initialMapZoom : 2; - aprsMap = L.map("aprs-map").setView(center, zoom); - const stage = mapStageEl(); - if (stage && typeof ResizeObserver !== "undefined") { - if (stageResizeObserver) stageResizeObserver.disconnect(); - stageResizeObserver = new ResizeObserver(() => { - sizeAprsMapToViewport(); - }); - stageResizeObserver.observe(stage); - } - updateMapBaseLayerForTheme(C.currentTheme()); - syncAprsReceiverMarker(); - aprsMap.on("popupopen", function(e) { - const marker = e.popup._source; - if (!marker) return; - clearMapRadioPath(); - setSelectedLocatorMarker(null); - if (selectedAprsTrackCall) { - const prevEntry = stationMarkers.get(String(selectedAprsTrackCall)); - if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) { - prevEntry.track.removeFrom(aprsMap); - } - selectedAprsTrackCall = null; - } - if (selectedAisTrackMmsi) { - selectedAisTrackMmsi = null; - syncSelectedAisTrackVisibility(); - } - if (marker._receiverLocKey) { - e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || [])); - return; - } - if (!marker) return; - const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; - if (marker._aprsCall) { - if (!ll) return; - const entry = stationMarkers.get(marker._aprsCall); - if (!entry) return; - e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); - refreshAprsTrack(String(marker._aprsCall), entry); - if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { - entry.track.addTo(aprsMap); - } - selectedAprsTrackCall = String(marker._aprsCall); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); - return; - } - if (marker._aisMmsi) { - if (!ll) return; - const entry = aisMarkers.get(String(marker._aisMmsi)); - if (!entry || !entry.msg) return; - e.popup.setContent(buildAisPopupHtml(entry.msg)); - refreshAisTrack(String(marker._aisMmsi), entry); - selectedAisTrackMmsi = String(marker._aisMmsi); - syncSelectedAisTrackVisibility(); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds); - return; - } - if (marker._vdesKey) { - if (!ll) return; - const entry = vdesMarkers.get(String(marker._vdesKey)); - if (!entry || !entry.msg) return; - e.popup.setContent(buildVdesPopupHtml(entry.msg)); - setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds); - return; - } - if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") { - const center2 = locatorMarkerCenter(marker); - if (center2) { - setSelectedLocatorMarker(marker); - const lEntry = locatorEntryForMarker(marker); - const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color ?? locatorFilterColor("ft8") : locatorFilterColor(marker.__trxType); - setMapRadioPathTo(center2.lat, center2.lon, lColor, "locator-radio-path", marker.__trxRigIds); - } - } else if (marker.__trxType === "bookmark") { - setSelectedLocatorMarker(marker); - } - }); - aprsMap.on("popupclose", function() { - clearMapRadioPath(); - setSelectedLocatorMarker(null); - if (selectedAprsTrackCall) { - const entry = stationMarkers.get(String(selectedAprsTrackCall)); - if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { - entry.track.removeFrom(aprsMap); - } - selectedAprsTrackCall = null; - } - if (selectedAisTrackMmsi) { - selectedAisTrackMmsi = null; - syncSelectedAisTrackVisibility(); - } - }); - materializeBufferedMapLayers(); - const locatorPhaseEl = mapEl("map-locator-phase"); - const locatorChoiceEl = mapEl("map-locator-choice-filter"); - const mapSearchEl = mapEl("map-search-filter"); - const mapHistoryLimitEl = mapEl("map-history-limit"); - const mapP2pPathsToggleEl = mapEl("map-p2p-paths-toggle"); - const mapContactPathsToggleEl = mapEl("map-contact-paths-toggle"); - const fullscreenBtn = mapEl("map-fullscreen-btn"); - const overlayToggleBtn = mapEl("map-overlay-toggle-btn"); - if (locatorPhaseEl) { - locatorPhaseEl.addEventListener("click", (e) => { - const btn = e.target?.closest(".map-locator-phase-btn[data-phase]"); - if (!btn) return; - const phase = String(btn.dataset.phase || ""); - if (phase !== "type" && phase !== "band") return; - if (mapLocatorFilter.phase === phase) return; - mapLocatorFilter.phase = phase; - rebuildMapLocatorFilters(); - applyMapFilter(); - }); - } - if (locatorChoiceEl) { - locatorChoiceEl.addEventListener("click", (e) => { - const chip = e.target?.closest(".map-locator-chip[data-filter-kind]"); - if (!chip) return; - const kind = String(chip.dataset.filterKind || ""); - const key = String(chip.dataset.filterKey || ""); - if (!key) return; - if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { - const sourceKey = key; - mapFilter[sourceKey] = !mapFilter[sourceKey]; - const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); - const anySelected = srcKeys.some((k) => mapFilter[k]); - if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { - const entry = stationMarkers.get(String(selectedAprsTrackCall)); - if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { - entry.track.removeFrom(aprsMap); - } - selectedAprsTrackCall = null; - } - if (anySelected && !mapFilter.ais && selectedAisTrackMmsi) { - const entry = aisMarkers.get(String(selectedAisTrackMmsi)); - if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { - entry.track.removeFrom(aprsMap); - } - selectedAisTrackMmsi = null; - } - } else if (kind === "band") { - if (mapLocatorFilter.bands.has(key)) { - mapLocatorFilter.bands.delete(key); - } else { - mapLocatorFilter.bands.add(key); - } - } - rebuildMapLocatorFilters(); - applyMapFilter(); - }); - } - const mapRigFilterEl = mapEl("map-rig-filter"); - if (mapRigFilterEl) { - mapRigFilterEl.addEventListener("change", () => { - mapRigFilter = mapRigFilterEl.value; - applyMapFilter(); - }); - } - if (mapSearchEl) { - mapSearchEl.value = mapSearchFilter; - mapSearchEl.addEventListener("input", () => { - mapSearchFilter = String(mapSearchEl.value || "").trim(); - applyMapFilter(); - }); - } - if (mapHistoryLimitEl) { - mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); - mapHistoryLimitEl.addEventListener("change", () => { - mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value)); - mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); - saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes); - pruneMapHistory(); - }); - } - if (mapP2pPathsToggleEl) { - updateMapP2pPathsToggle(); - mapP2pPathsToggleEl.addEventListener("click", () => { - mapP2pRadioPathsEnabled = !mapP2pRadioPathsEnabled; - saveSetting("mapP2pRadioPathsEnabled", mapP2pRadioPathsEnabled); - updateMapP2pPathsToggle(); - if (!mapP2pRadioPathsEnabled) clearMapRadioPath(); - }); - } - if (mapContactPathsToggleEl) { - updateMapContactPathsToggle(); - mapContactPathsToggleEl.addEventListener("click", () => { - mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled; - saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled); - updateMapContactPathsToggle(); - syncDecodeContactPathVisibility(); - }); - } - if (fullscreenBtn) { - fullscreenBtn.addEventListener("click", () => { - void toggleMapFullscreen(); - }); - updateMapFullscreenButton(); - } - applyMapOverlayPanelVisibility(); - updateMapOverlayToggleButton(); - if (overlayToggleBtn) { - overlayToggleBtn.addEventListener("click", () => { - mapOverlayPanelVisible = !mapOverlayPanelVisible; - saveSetting("mapOverlayPanelVisible", mapOverlayPanelVisible); - applyMapOverlayPanelVisibility(); - updateMapOverlayToggleButton(); - }); - } - if (!mapFullscreenListenerBound) { - const onFullscreenChange = () => { - updateMapFullscreenButton(); - sizeAprsMapToViewport(); - }; - document.addEventListener("fullscreenchange", onFullscreenChange); - document.addEventListener("webkitfullscreenchange", onFullscreenChange); - mapFullscreenListenerBound = true; - } - if (!mapHistoryPruneTimer) { - mapHistoryPruneTimer = setInterval(() => { - pruneMapHistory(); - }, 60 * 1e3); - } - rebuildMapLocatorFilters(); - } - function sizeAprsMapToViewport() { - const mapContainer = mapEl("aprs-map"); - const stage = mapStageEl(); - if (mapIsFullscreen() && stage) { - const isFake = stage.classList.contains("map-fake-fullscreen"); - const stageHeight = isFake ? window.innerHeight : stage.clientHeight || stage.getBoundingClientRect().height; - const target2 = Math.max(260, Math.floor(stageHeight)); - mapContainer.style.height = `${target2}px`; - if (aprsMap) aprsMap.invalidateSize(); - return; - } - const mapRect = mapContainer.getBoundingClientRect(); - const width = mapContainer.clientWidth || mapRect.width; - const footer = document.querySelector(".footer"); - let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight; - if (!mapIsFullscreen() && footer) { - const fr = footer.getBoundingClientRect(); - if (fr.top > mapRect.top + 50) bottom = fr.top; - } - const available = Math.max(0, Math.floor(bottom - mapRect.top - 8)); - const widthDriven = width > 0 ? Math.floor(width / 1.55) : available; - const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75); - const minHeight = Math.min(260, available); - const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); - mapContainer.style.height = `${target}px`; - if (aprsMap) aprsMap.invalidateSize(); - } - function aprsSymbolIcon(symbolTable, symbolCode) { - if (!symbolTable || !symbolCode) return null; - const table = symbolTable === "/" ? "primary" : "alternate"; - return L.divIcon({ - className: "", - html: `
${escapeMapHtml(symbolCode)}
`, - iconSize: [24, 24], - iconAnchor: [12, 12], - popupAnchor: [0, -12] - }); - } - mapWindow.navigateToAprsMap = function(lat, lon) { - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => { - t.classList.remove("active"); - }); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const mapPanel = mapEl("tab-map"); - if (mapPanel) mapPanel.style.display = ""; - initAprsMap(); - sizeAprsMapToViewport(); - if (aprsMap) { - requestAnimationFrame(() => { - requestAnimationFrame(() => { - sizeAprsMapToViewport(); - aprsMap?.invalidateSize(); - aprsMap?.setView([lat, lon], 13); - }); - }); - } - }; - mapWindow.navigateToMapLocator = function(grid, preferredType = null) { - const normalizedGrid = String(grid || "").trim().toUpperCase(); - if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; - T._activeTab = "map"; - document.querySelectorAll(".tab-bar .tab").forEach((t) => { - t.classList.remove("active"); - }); - const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); - if (mapTabBtn) mapTabBtn.classList.add("active"); - document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); - const mapPanel = mapEl("tab-map"); - if (mapPanel) mapPanel.style.display = ""; - initAprsMap(); - sizeAprsMapToViewport(); - if (!aprsMap) return false; - const pref = preferredType === "wspr" ? "wspr" : preferredType === "ft4" ? "ft4" : preferredType === "ft2" ? "ft2" : preferredType === "ft8" ? "ft8" : null; - const keys = pref ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; - let entry = null; - for (const key of keys) { - entry = locatorMarkers.get(key); - if (entry?.marker) break; - } - if (!entry?.marker) return false; - if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) { - mapFilter[pref] = true; - rebuildMapLocatorFilters(); - applyMapFilter(); - } - const marker = entry.marker; - if (!aprsMap.hasLayer(marker)) { - marker.addTo(aprsMap); - sendLocatorOverlayToBack(marker); - } - const center = locatorMarkerCenter(marker); - const focusMarker = () => { - if (!aprsMap || !marker) return; - sizeAprsMapToViewport(); - aprsMap.invalidateSize(); - if (center) { - const targetZoom = Math.max(aprsMap.getZoom() || 0, 7); - aprsMap.setView([center.lat, center.lon], targetZoom); - if (marker.__trxType !== "bookmark") { - const fEntry = locatorEntryForMarker(marker); - const sourceType = marker.__trxType; - const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color ?? locatorFilterColor(sourceType) : locatorFilterColor(sourceType); - setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); - } - } - setSelectedLocatorMarker(marker); - if (typeof marker.openPopup === "function") marker.openPopup(); - }; - focusMarker(); - requestAnimationFrame(() => { - requestAnimationFrame(focusMarker); - }); - return true; - }; - function buildReceiverPopupHtml(rigIds) { - const call = T.serverCallsign || T.ownerCallsign || "Receiver"; - let meta = ""; - if (T.serverVersion) { - meta = `trx-server v${escapeMapHtml(T.serverVersion)}`; - if (T.serverBuildDate) meta += ` · ${escapeMapHtml(T.serverBuildDate)}`; - } - let rows = ""; - if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) { - rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; - } - const rigList = rigIds ? Array.from(rigIds) : []; - const rigSet = rigList.length ? new Set(rigList) : null; - const firstRig = rigSet ? T.serverRigs.find((r) => !!r.remote && rigSet.has(r.remote)) : null; - const popupLat = firstRig ? firstRig.latitude : T.serverLat; - const popupLon = firstRig ? firstRig.longitude : T.serverLon; - if (popupLat != null && popupLon != null) { - const grid = latLonToMaidenhead(popupLat, popupLon); - rows += `QTH${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)})`; - } - const rigsToShow = rigSet ? T.serverRigs.filter((r) => !!r.remote && rigSet.has(r.remote)) : T.serverRigs; - for (const rig of rigsToShow) { - const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); - const active = rig.remote === T.serverActiveRigId ? ` active` : ""; - rows += `Rig${escapeMapHtml(name)}${active}`; - } - return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + `
`; - } - function buildAprsPopupHtml(call, lat, lon, info, pkt) { - const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : pkt?._ts || null; - const distKm = T.serverLat != null && T.serverLon != null ? haversineKm(T.serverLat, T.serverLon, lat, lon) : null; - const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; - const path = pkt?.path || null; - const type = pkt?.type || null; - const meta = [age, distStr].filter(Boolean).join(" · "); - let rows = ""; - if (type) rows += `Type${escapeMapHtml(type)}`; - if (path) rows += `Path${escapeMapHtml(path)}`; - if (lat != null && lon != null) - rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; - return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${escapeMapHtml(info)}
` : "") + `
`; - } - function buildAisPopupHtml(msg) { - const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; - const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; - const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; - const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · "); - let rows = ""; - rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; - rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; - if (distStr) rows += `Range${distStr} from TRX`; - if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; - if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; - if (msg?.heading_deg != null) rows += `HDG${Number(msg.heading_deg).toFixed(0)}°`; - if (msg?.nav_status != null) rows += `Nav${escapeMapHtml(String(msg.nav_status))}`; - if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; - const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); - const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); - const vesselUrl = mapWindow.buildAisVesselUrl ? mapWindow.buildAisVesselUrl(msg?.mmsi) : null; - const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; - return `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; - } - function buildVdesPopupHtml(msg) { - const age = msg.ts_ms == null ? "" : formatTimeAgo(msg.ts_ms); - const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; - const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; - const meta = [ - age, - distStr, - msg?.message_label ? escapeMapHtml(msg.message_label) : null, - Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null - ].filter(Boolean).join(" · "); - let rows = ""; - if (distStr) rows += `Range${distStr} from TRX`; - rows += `Type${escapeMapHtml(String(msg?.message_type ?? "--"))}`; - if (Number.isFinite(msg?.source_id)) rows += `Source${escapeMapHtml(String(msg.source_id))}`; - if (Number.isFinite(msg?.destination_id)) rows += `Dest${escapeMapHtml(String(msg.destination_id))}`; - if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; - if (Number.isFinite(msg?.sync_score)) rows += `Sync${(Number(msg.sync_score) * 100).toFixed(0)}%`; - if (msg?.fec_state) rows += `FEC${escapeMapHtml(String(msg.fec_state))}`; - const info = [ - msg?.vessel_name, + msg?.name, msg?.callsign, msg?.destination, - msg?.payload_preview - ].filter(Boolean).map(escapeMapHtml).join(" · "); - const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position"); - return `
${title}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + msg && Number.isFinite(msg.mmsi) ? String(msg.mmsi) : "", + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" + ].join(" ").toLowerCase(); } - function aprsPositionsEqual(a, b) { - if (!a || !b) return false; - const aLat = Array.isArray(a) ? a[0] : a.lat; - const aLon = Array.isArray(a) ? a[1] : a.lon; - const bLat = Array.isArray(b) ? b[0] : b.lat; - const bLon = Array.isArray(b) ? b[1] : b.lon; - return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + if (type === "vdes") { + const key = marker?._vdesKey ? String(marker._vdesKey) : ""; + const msg = vdesMarkers.get(key)?.msg; + return [ + key, + msg?.name, + msg?.mmsi, + msg?.message, + msg?.raw, + msg && Number.isFinite(msg.lat) ? String(msg.lat) : "", + msg && Number.isFinite(msg.lon) ? String(msg.lon) : "" + ].join(" ").toLowerCase(); } - function aisPositionsEqual(a, b) { - if (!a || !b) return false; - const aLat = Array.isArray(a) ? a[0] : a.lat; - const aLon = Array.isArray(a) ? a[1] : a.lon; - const bLat = Array.isArray(b) ? b[0] : b.lat; - const bLon = Array.isArray(b) ? b[1] : b.lon; - return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + return ""; + } + function markerPassesSearchFilter(marker) { + const query = String(mapSearchFilter || "").trim().toLowerCase(); + if (!query) return true; + const terms = query.split(/\s+/).filter(Boolean); + if (terms.length === 0) return true; + const haystack = markerSearchText(marker); + if (!haystack) return false; + return terms.every((term) => haystack.includes(term)); + } + function _receiverLocationKey(lat, lon) { + return lat.toFixed(6) + "," + lon.toFixed(6); + } + function syncAprsReceiverMarker() { + if (!aprsMap) return; + const locGroups = {}; + const activeId = T.lastActiveRigId || T.serverActiveRigId || null; + for (const rig of T.serverRigs) { + if (!rig || !rig.remote) continue; + const lat = rig.latitude, lon = rig.longitude; + if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) continue; + const key = _receiverLocationKey(lat, lon); + const group = locGroups[key] ??= { lat, lon, rigs: /* @__PURE__ */ new Set(), hasActive: false }; + group.rigs.add(rig.remote); + if (rig.remote === activeId) group.hasActive = true; } - function vdesMarkerKey(msg) { - if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; - if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; - if (msg?.lat != null && msg?.lon != null) { - return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`; - } - return null; + if (T.serverLat != null && T.serverLon != null) { + const key = _receiverLocationKey(T.serverLat, T.serverLon); + const group = locGroups[key] ??= { lat: T.serverLat, lon: T.serverLon, rigs: /* @__PURE__ */ new Set(), hasActive: true }; + group.hasActive = true; } - function _aprsAddMarkerToMap(call, entry) { - if (!aprsMap || entry.lat == null || entry.lon == null) return; - refreshAprsTrack(call, entry); - const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); - const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); - const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { - radius: 6, - color: "#00d17f", - fillColor: "#00d17f", - fillOpacity: 0.8 - }).addTo(aprsMap).bindPopup(popupContent); - marker.__trxType = "aprs"; - marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); - marker._aprsCall = call; - entry.marker = marker; - mapMarkers.add(marker); - } - mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { - const nextPoint = [lat, lon]; - const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); - const msgRigId = pkt?.rig_id || T.lastActiveRigId; - const existing = stationMarkers.get(call); - if (existing) { - existing.pkt = pkt; - existing.lat = lat; - existing.lon = lon; - existing.info = info; - existing.symbolTable = symbolTable; - existing.symbolCode = symbolCode; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } - if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; - const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; - if (!aprsPositionsEqual(prevPoint, nextPoint)) { - existing.trackHistory.push({ lat, lon, tsMs }); - } else if (prevPoint) { - prevPoint.tsMs = tsMs; - } - pruneAprsEntry(call, existing, mapHistoryCutoffMs()); - if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { - existing.marker.setLatLng([lat, lon]); - existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); + const seen = /* @__PURE__ */ new Set(); + let didInitialView = false; + for (const [key, group] of Object.entries(locGroups)) { + seen.add(key); + const latLng = [group.lat, group.lon]; + const isActive = group.hasActive; + let m = aprsMapReceiverMarkers[key]; + if (!m) { + m = L.circleMarker(latLng, { + radius: isActive ? 8 : 6, + className: "trx-receiver-marker" + (isActive ? "" : " trx-receiver-marker-secondary"), + fillOpacity: isActive ? 0.8 : 0.6 + }).addTo(aprsMap).bindPopup(""); + m._receiverLocKey = key; + m._receiverRigs = group.rigs; + aprsMapReceiverMarkers[key] = m; + if (isActive && !didInitialView) { + aprsMap.setView(latLng, Math.max(1, T.initialMapZoom)); + didInitialView = true; } } else { - const entry = { - marker: null, - track: null, - trackHistory: [{ lat, lon, tsMs }], - trackPoints: [nextPoint], - type: "aprs", - pkt, - lat, - lon, - info, - symbolTable, - symbolCode, - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - stationMarkers.set(call, entry); - pruneAprsEntry(call, entry, mapHistoryCutoffMs()); - if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); - if (aprsMap) scheduleDecodeMapMaintenance(); + m.setLatLng(latLng); + m._receiverRigs = group.rigs; + m.setRadius(isActive ? 8 : 6); + if (!aprsMap.hasLayer(m)) m.addTo(aprsMap); } - }; - function syncSelectedAisTrackVisibility() { - if (!aprsMap) return; - const map = aprsMap; - const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; - aisMarkers.forEach((entry, key) => { - const track = entry?.track; - if (!track) return; - const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; - const onMap = map.hasLayer(track); - if (shouldShow && !onMap) { - track.addTo(map); + } + for (const key of Object.keys(aprsMapReceiverMarkers)) { + if (!seen.has(key)) { + const m = aprsMapReceiverMarkers[key]; + if (m && aprsMap.hasLayer(m)) m.removeFrom(aprsMap); + Reflect.deleteProperty(aprsMapReceiverMarkers, key); + } + } + } + const satOverlays = /* @__PURE__ */ new Map(); + let satOverlaySeq = 0; + mapWindow.addSatMapOverlay = function(msg) { + if (!msg || !Array.isArray(msg.geo_bounds) || typeof msg.path !== "string") return; + const bounds = msg.geo_bounds; + if (!Array.isArray(bounds) || bounds.length !== 4) return; + const latLngBounds = L.latLngBounds( + [Number(bounds[0]), Number(bounds[1])], + // SW + [Number(bounds[2]), Number(bounds[3])] + // NE + ); + const key = `sat-${++satOverlaySeq}`; + const overlay = L.imageOverlay(msg.path, latLngBounds, { + opacity: 0.55, + interactive: true, + zIndex: 300 + }); + overlay.__trxType = "sat"; + overlay.__trxSatKey = key; + overlay.__trxRigIds = msg.rig_id ? /* @__PURE__ */ new Set([msg.rig_id]) : /* @__PURE__ */ new Set(); + overlay.__trxHistoryVisible = true; + mapMarkers.add(overlay); + const decoder = "Meteor LRPT"; + const satellite = msg.satellite || "Unknown"; + const ts = msg.ts_ms ? new Date(msg.ts_ms).toLocaleString() : ""; + overlay.bindPopup( + `
${escapeMapHtml(decoder)}
${escapeMapHtml(satellite)}
${escapeMapHtml(ts)}
` + (msg.path ? `Download PNG` : "") + `
` + ); + let track = null; + if (msg.ground_track && Array.isArray(msg.ground_track) && msg.ground_track.length >= 2) { + const latlngs = msg.ground_track.filter((pt) => Array.isArray(pt) && pt.length >= 2).map((pt) => [Number(pt[0]), Number(pt[1])]); + track = L.polyline(latlngs, { + color: mapSourceColor("sat"), + weight: 2, + opacity: 0.7, + dashArray: "6, 4" + }); + track.__trxType = "sat"; + track.__trxSatKey = key; + track.__trxRigIds = overlay.__trxRigIds ?? /* @__PURE__ */ new Set(); + track.__trxHistoryVisible = true; + mapMarkers.add(track); + if (aprsMap) { + track.addTo(aprsMap); + } + } + satOverlays.set(key, { overlay, track, msg }); + if (aprsMap) { + overlay.addTo(aprsMap); + } + applyMapFilter(); + }; + mapWindow.removeSatMapOverlay = function(key) { + const entry = satOverlays.get(key); + if (!entry) return; + if (entry.overlay) { + mapMarkers.delete(entry.overlay); + if (aprsMap && aprsMap.hasLayer(entry.overlay)) entry.overlay.removeFrom(aprsMap); + } + if (entry.track) { + mapMarkers.delete(entry.track); + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + } + satOverlays.delete(key); + }; + mapWindow.clearSatMapOverlays = function() { + for (const [key] of satOverlays) { + mapWindow.removeSatMapOverlay?.(key); + } + }; + mapWindow.clearMapMarkersByType = function(type) { + if (type === "aprs") { + selectedAprsTrackCall = null; + stationMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); } - if (!shouldShow && onMap) { - track.removeFrom(map); + if (entry && entry.track) { + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + mapMarkers.delete(entry.track); } }); + stationMarkers.clear(); + return; } - function getAisAccentColor() { - return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; + if (type === "ais") { + aisMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + if (entry && entry.track) { + if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap); + mapMarkers.delete(entry.track); + } + }); + selectedAisTrackMmsi = null; + aisMarkers.clear(); + return; } - function aisMarkerOptionsFromMessage(msg) { - const color = getAisAccentColor(); + if (type === "vdes") { + vdesMarkers.forEach((entry) => { + if (entry && entry.marker) { + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + }); + vdesMarkers.clear(); + return; + } + if (type === "sat") { + mapWindow.clearSatMapOverlays?.(); + return; + } + if (type === "ft8" || type === "ft4" || type === "ft2" || type === "wspr") { + const prefix = `${type}:`; + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith(prefix)) continue; + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + rebuildMapLocatorFilters(); + rebuildDecodeContactPaths(); + } + if (type === "bookmark") { + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + rebuildMapLocatorFilters(); + } + }; + function mapTileSpecForTheme(theme) { + if (theme === "dark") { return { - heading: msg?.heading_deg, - course: msg?.cog_deg, - speed: msg?.sog_knots, - color, - outline: "#00000055", - size: 22 + url: "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", + options: { + maxZoom: 19, + subdomains: "abcd", + attribution: '© OpenStreetMap © CARTO' + } }; } - function createAisMarker(lat, lon, msg) { - if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { - return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); + return { + url: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", + options: { + maxZoom: 19, + attribution: '© OpenStreetMap' } - const color = getAisAccentColor(); - return L.circleMarker([lat, lon], { - radius: 6, - color, - fillColor: color, - fillOpacity: 0.82 + }; + } + function updateMapBaseLayerForTheme(theme) { + if (!aprsMap) return; + if (aprsMapBaseLayer) { + aprsMap.removeLayer(aprsMapBaseLayer); + aprsMapBaseLayer = null; + } + const spec = mapTileSpecForTheme(theme); + aprsMapBaseLayer = L.tileLayer(spec.url, spec.options).addTo(aprsMap); + } + function mapStageEl() { + return mapEl("map-stage"); + } + function mapIsFullscreen() { + const stage = mapStageEl(); + if (!stage) return false; + return document.fullscreenElement === stage || document.webkitFullscreenElement === stage || stage.classList.contains("map-fake-fullscreen"); + } + function mapExitFakeFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + stage.classList.remove("map-fake-fullscreen"); + document.body.classList.remove("map-fake-fullscreen-active"); + } + function mapEnterFakeFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + stage.classList.add("map-fake-fullscreen"); + document.body.classList.add("map-fake-fullscreen-active"); + } + function updateMapFullscreenButton() { + const btn = mapEl("map-fullscreen-btn"); + if (!btn) return; + btn.textContent = mapIsFullscreen() ? "Exit Fullscreen" : "Fullscreen"; + } + function applyMapOverlayPanelVisibility() { + const panel = document.querySelector("#map-stage .map-overlay-panel"); + if (!panel) return; + panel.classList.toggle("is-hidden", !mapOverlayPanelVisible); + } + function updateMapOverlayToggleButton() { + const btn = mapEl("map-overlay-toggle-btn"); + if (!btn) return; + btn.textContent = mapOverlayPanelVisible ? "Hide Filters" : "Show Filters"; + } + async function toggleMapFullscreen() { + const stage = mapStageEl(); + if (!stage) return; + try { + const webkitDocument = document; + const isNative = document.fullscreenElement === stage || webkitDocument.webkitFullscreenElement === stage; + const isFake = stage.classList.contains("map-fake-fullscreen"); + if (isNative) { + if (document.exitFullscreen) await document.exitFullscreen(); + else if (webkitDocument.webkitExitFullscreen) await webkitDocument.webkitExitFullscreen(); + } else if (isFake) { + mapExitFakeFullscreen(); + } else { + const nativeFn = stage.requestFullscreen || stage.webkitRequestFullscreen; + if (nativeFn) { + try { + await nativeFn.call(stage); + } catch { + mapEnterFakeFullscreen(); + } + } else { + mapEnterFakeFullscreen(); + } + } + } catch (err) { + console.error("Map fullscreen toggle failed", err); + } finally { + updateMapFullscreenButton(); + requestAnimationFrame(() => { + sizeAprsMapToViewport(); }); } - function updateAisMarker(marker, msg, popupHtml) { - if (!marker || msg.lat == null || msg.lon == null) return; - marker.setLatLng([msg.lat, msg.lon]); - if (typeof marker.setAisState === "function") { - marker.setAisState(aisMarkerOptionsFromMessage(msg)); - } - if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { - const color = getAisAccentColor(); - marker.setRadius(6); - marker.setStyle({ - color, - fillColor: color, - fillOpacity: 0.84 + } + document.addEventListener("keydown", (e) => { + if (e.key === "Escape") { + const stage = mapStageEl(); + if (stage && stage.classList.contains("map-fake-fullscreen")) { + mapExitFakeFullscreen(); + updateMapFullscreenButton(); + requestAnimationFrame(() => { + sizeAprsMapToViewport(); }); } - marker.setPopupContent(popupHtml); } - function refreshAisMarkerColors() { - const color = getAisAccentColor(); - aisMarkers.forEach((entry) => { - if (entry.marker) { - if (typeof entry.marker.setAisState === "function") { - entry.marker.setAisState(aisMarkerOptionsFromMessage(entry.msg || {})); - } else if (typeof entry.marker.setStyle === "function") { - entry.marker.setStyle({ color, fillColor: color }); - } - } - if (entry.track && typeof entry.track.setStyle === "function") { - entry.track.setStyle({ color }); - } + }); + function materializeBufferedMapLayers() { + if (!aprsMap) return; + for (const [key, entry] of locatorMarkers) { + if (!key.startsWith("bookmark:") || entry?.marker || !entry?.grid) continue; + const bounds = maidenheadToBounds(entry.grid); + if (!bounds) continue; + entry.sourceType = "bookmark"; + entry.bandMeta = collectBandMeta((entry.bookmarks || []).map((bm) => Number(bm?.freq_hz))); + entry.marker = L.rectangle(bounds, locatorStyleForEntry(entry, entry.bookmarks?.length || 1)).addTo(aprsMap).bindPopup(buildBookmarkLocatorPopupHtml(entry.grid, entry.bookmarks || [])); + entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); + assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); + mapMarkers.add(entry.marker); + } + pruneMapHistory(); + } + let stageResizeObserver = null; + function initAprsMap() { + if (typeof L === "undefined") return; + mapEl("aprs-map"); + sizeAprsMapToViewport(); + if (aprsMap) return; + const hasLocation = T.serverLat != null && T.serverLon != null; + const center = hasLocation && T.serverLat != null && T.serverLon != null ? [T.serverLat, T.serverLon] : [20, 0]; + const zoom = hasLocation ? T.initialMapZoom : 2; + aprsMap = L.map("aprs-map").setView(center, zoom); + const stage = mapStageEl(); + if (stage && typeof ResizeObserver !== "undefined") { + if (stageResizeObserver) stageResizeObserver.disconnect(); + stageResizeObserver = new ResizeObserver(() => { + sizeAprsMapToViewport(); }); + stageResizeObserver.observe(stage); } - mapWindow.aisMapAddVessel = function(msg) { - if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; - const key = String(msg.mmsi); - const popupHtml = buildAisPopupHtml(msg); - const nextPoint = [msg.lat, msg.lon]; - const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now(); - const msgRigId = msg?.rig_id || T.lastActiveRigId; - const existing = aisMarkers.get(key); - if (existing) { - existing.msg = msg; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } - if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; - const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; - if (!aisPositionsEqual(prevPoint, nextPoint)) { - existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs }); - } else if (prevPoint) { - prevPoint.tsMs = tsMs; - } - pruneAisEntry(key, existing, mapHistoryCutoffMs()); - if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { - updateAisMarker(existing.marker, msg, popupHtml); + updateMapBaseLayerForTheme(C.currentTheme()); + syncAprsReceiverMarker(); + aprsMap.on("popupopen", function(e) { + const marker = e.popup._source; + if (!marker) return; + clearMapRadioPath(); + setSelectedLocatorMarker(null); + if (selectedAprsTrackCall) { + const prevEntry = stationMarkers.get(String(selectedAprsTrackCall)); + if (prevEntry && prevEntry.track && aprsMap && aprsMap.hasLayer(prevEntry.track)) { + prevEntry.track.removeFrom(aprsMap); } + selectedAprsTrackCall = null; + } + if (selectedAisTrackMmsi) { + selectedAisTrackMmsi = null; + syncSelectedAisTrackVisibility(); + } + if (marker._receiverLocKey) { + e.popup.setContent(buildReceiverPopupHtml(marker._receiverRigs || [])); return; } - const entry = { - marker: null, - track: null, - trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], - trackPoints: [nextPoint], - msg, - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - aisMarkers.set(key, entry); - pruneAisEntry(key, entry, mapHistoryCutoffMs()); - if (entry.visibleInHistoryWindow) ensureAisMarker(key, entry); - scheduleDecodeMapMaintenance(); - }; - mapWindow.vdesMapAddPoint = function(msg) { - if (msg == null || msg.lat == null || msg.lon == null) return; - const key = vdesMarkerKey(msg); - if (!key) return; - const popupHtml = buildVdesPopupHtml(msg); - const visible = Number.isFinite(Number(msg?._tsMs)) && Number(msg._tsMs) >= mapHistoryCutoffMs(); - const msgRigId = msg?.rig_id || T.lastActiveRigId; - const existing = vdesMarkers.get(key); - if (existing) { - existing.msg = msg; - existing.visibleInHistoryWindow = visible; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } - if (!visible) { - if (!C.decodeHistoryMapRenderingDeferred()) { - setRetainedMapMarkerVisible(existing.marker, false); - } else { - C.markDecodeMapSyncPending(); - } - return; - } - if (!C.decodeHistoryMapRenderingDeferred()) { - ensureVdesMarker(key, existing); - setRetainedMapMarkerVisible(existing.marker, true); - } else { - C.markDecodeMapSyncPending(); - } - if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { - existing.marker.setLatLng([msg.lat, msg.lon]); - existing.marker.setPopupContent(popupHtml); + if (!marker) return; + const ll = typeof marker.getLatLng === "function" ? marker.getLatLng() : null; + if (marker._aprsCall) { + if (!ll) return; + const entry = stationMarkers.get(marker._aprsCall); + if (!entry) return; + e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); + refreshAprsTrack(String(marker._aprsCall), entry); + if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { + entry.track.addTo(aprsMap); } + selectedAprsTrackCall = String(marker._aprsCall); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("aprs"), "aprs-radio-path", marker.__trxRigIds); return; } - const entry = { - marker: null, - msg, - visibleInHistoryWindow: visible, - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - vdesMarkers.set(key, entry); - if (!visible) return; - if (!C.decodeHistoryMapRenderingDeferred()) { - ensureVdesMarker(key, entry); - setRetainedMapMarkerVisible(entry.marker, true); - } else { - C.markDecodeMapSyncPending(); - } - if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) { - entry.marker.setPopupContent(popupHtml); - } - scheduleDecodeMapMaintenance(); - }; - let reverseGeocodeLastKey = null; - function reverseGeocodeLocation(lat, lon, grid) { - const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; - if (key === reverseGeocodeLastKey) return; - reverseGeocodeLastKey = key; - const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; - fetch(url, { headers: { "User-Agent": "trx-rs" } }).then((r) => r.ok ? r.json() : Promise.reject(new Error(`Reverse geocode failed: ${r.status}`))).then((data) => { - const addr = data?.address; - if (!addr) return; - const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; - const country = addr.country || ""; - if (!city && !country) return; - const label = city && country ? `${city}, ${country}` : city || country; - T.lastCityLabel = label; - if (T.locationSubtitle) { - T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; - } - C.updateDocumentTitle?.(); - }).catch(() => { - }); - } - function maidenheadToBounds(grid) { - if (!grid || grid.length < 4) return null; - const g = grid.toUpperCase(); - const A = "A".charCodeAt(0); - const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; - const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; - const squareLon = parseInt(g.charAt(2), 10) * 2; - const squareLat = parseInt(g.charAt(3), 10); - let lon = fieldLon + squareLon; - let lat = fieldLat + squareLat; - let lonSpan = 2; - let latSpan = 1; - if (g.length >= 6) { - const subLon = (g.charCodeAt(4) - A) * (5 / 60); - const subLat = (g.charCodeAt(5) - A) * (2.5 / 60); - lon += subLon; - lat += subLat; - lonSpan = 5 / 60; - latSpan = 2.5 / 60; - } - return [ - [lat, lon], - [lat + latSpan, lon + lonSpan] - ]; - } - function applyMapFilter() { - if (!aprsMap) return; - const map = aprsMap; - const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); - const noneSelected = sourceKeys.every((k) => !mapFilter[k]); - mapMarkers.forEach((marker) => { - const type = marker.__trxType; - const sourceVisible = noneSelected ? type == null || DEFAULT_MAP_SOURCE_FILTER[type] : type == null || !!mapFilter[type]; - const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter); - const visible = marker.__trxHistoryVisible !== false && markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; - const onMap = map.hasLayer(marker); - if (visible && !onMap) { - marker.addTo(map); - sendLocatorOverlayToBack(marker); - } - if (!visible && onMap) marker.removeFrom(map); - }); - syncSelectedAisTrackVisibility(); - syncDecodeContactPathVisibility(); - } - function updateMapContactPathsToggle() { - const btn = mapEl("map-contact-paths-toggle"); - if (!btn) return; - btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; - btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); - } - function updateMapP2pPathsToggle() { - const btn = mapEl("map-p2p-paths-toggle"); - if (!btn) return; - btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; - btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); - } - function scheduleDecodeMapMaintenance() { - if (C.decodeHistoryMapRenderingDeferred()) { - C.markDecodeMapSyncPending(); + if (marker._aisMmsi) { + if (!ll) return; + const entry = aisMarkers.get(String(marker._aisMmsi)); + if (!entry || !entry.msg) return; + e.popup.setContent(buildAisPopupHtml(entry.msg)); + refreshAisTrack(String(marker._aisMmsi), entry); + selectedAisTrackMmsi = String(marker._aisMmsi); + syncSelectedAisTrackVisibility(); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path", marker.__trxRigIds); return; } - scheduleUiFrameJob("decode-map-maintenance", () => { - rebuildDecodeContactPaths(); + if (marker._vdesKey) { + if (!ll) return; + const entry = vdesMarkers.get(String(marker._vdesKey)); + if (!entry || !entry.msg) return; + e.popup.setContent(buildVdesPopupHtml(entry.msg)); + setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("vdes"), "aprs-radio-path", marker.__trxRigIds); + return; + } + if (marker.__trxType === "ft8" || marker.__trxType === "ft4" || marker.__trxType === "ft2" || marker.__trxType === "wspr") { + const center2 = locatorMarkerCenter(marker); + if (center2) { + setSelectedLocatorMarker(marker); + const lEntry = locatorEntryForMarker(marker); + const lColor = lEntry ? locatorStyleForEntry(lEntry, locatorEntryCount(lEntry)).color ?? locatorFilterColor("ft8") : locatorFilterColor(marker.__trxType); + setMapRadioPathTo(center2.lat, center2.lon, lColor, "locator-radio-path", marker.__trxRigIds); + } + } else if (marker.__trxType === "bookmark") { + setSelectedLocatorMarker(marker); + } + }); + aprsMap.on("popupclose", function() { + clearMapRadioPath(); + setSelectedLocatorMarker(null); + if (selectedAprsTrackCall) { + const entry = stationMarkers.get(String(selectedAprsTrackCall)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); + } + selectedAprsTrackCall = null; + } + if (selectedAisTrackMmsi) { + selectedAisTrackMmsi = null; + syncSelectedAisTrackVisibility(); + } + }); + materializeBufferedMapLayers(); + const locatorPhaseEl = mapEl("map-locator-phase"); + const locatorChoiceEl = mapEl("map-locator-choice-filter"); + const mapSearchEl = mapEl("map-search-filter"); + const mapHistoryLimitEl = mapEl("map-history-limit"); + const mapP2pPathsToggleEl = mapEl("map-p2p-paths-toggle"); + const mapContactPathsToggleEl = mapEl("map-contact-paths-toggle"); + const fullscreenBtn = mapEl("map-fullscreen-btn"); + const overlayToggleBtn = mapEl("map-overlay-toggle-btn"); + if (locatorPhaseEl) { + locatorPhaseEl.addEventListener("click", (e) => { + const btn = e.target?.closest(".map-locator-phase-btn[data-phase]"); + if (!btn) return; + const phase = String(btn.dataset.phase || ""); + if (phase !== "type" && phase !== "band") return; + if (mapLocatorFilter.phase === phase) return; + mapLocatorFilter.phase = phase; rebuildMapLocatorFilters(); applyMapFilter(); }); } - function formatDecodeLocatorTime(tsMs) { - if (!Number.isFinite(tsMs)) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { - hour: "2-digit", - minute: "2-digit", - second: "2-digit" - }); - } - function formatMapPopupFreq(hz) { - if (!Number.isFinite(hz)) return "--"; - const value = Number(hz); - if (value >= 1e9) return `${(value / 1e9).toFixed(6).replace(/\.?0+$/, "")} GHz`; - if (value >= 1e6) return `${(value / 1e6).toFixed(6).replace(/\.?0+$/, "")} MHz`; - if (value >= 1e3) return `${(value / 1e3).toFixed(3).replace(/\.?0+$/, "")} kHz`; - return `${Math.round(value)} Hz`; - } - function buildDecodeLocatorTooltipHtml(grid, entry, type) { - const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; - details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0)); - const title = type === "wspr" ? "WSPR" : "FT8"; - const rows = details.map((detail) => { - const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); - const freq = formatMapPopupFreq(Number(detail?.freq_hz)); - const meta = [ - detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, - Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, - Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, - escapeMapHtml(freq) - ].filter(Boolean).join(" · "); - const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 ? Array.from(detail.remotes) : detail?.remote ? [detail.remote] : []; - const rxHtml = remoteIds.map((rid) => { - const label = _receiverLabel(rid); - return label ? `
${escapeMapHtml(label)}
` : ""; - }).filter(Boolean).join(""); - const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; - return `
${station}${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}
` + (meta ? `
${meta}
` : "") + rxHtml + message + `
`; - }).join(""); - const count = Math.max( - 1, - details.length, - entry?.stations instanceof Set ? entry.stations.size : 0 - ); - return `
${escapeMapHtml(grid)}
${title} · ${count} station${count === 1 ? "" : "s"}
` + rows + `
`; - } - function rebuildDecodeContactPaths() { - clearDecodeContactPaths(); - const stationLocators = /* @__PURE__ */ new Map(); - const directedMessages = []; - for (const entry of locatorMarkers.values()) { - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; - const grid = String(entry.grid || "").trim().toUpperCase(); - if (!grid || !(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); - const target = String(detail?.target || "").trim().toUpperCase(); - const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; - if (source) { - const prev = stationLocators.get(source); - if (!prev || tsMs >= prev.tsMs) { - stationLocators.set(source, { grid, tsMs }); + if (locatorChoiceEl) { + locatorChoiceEl.addEventListener("click", (e) => { + const chip = e.target?.closest(".map-locator-chip[data-filter-kind]"); + if (!chip) return; + const kind = String(chip.dataset.filterKind || ""); + const key = String(chip.dataset.filterKey || ""); + if (!key) return; + if (kind === "source" && Object.prototype.hasOwnProperty.call(mapFilter, key)) { + const sourceKey = key; + mapFilter[sourceKey] = !mapFilter[sourceKey]; + const srcKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const anySelected = srcKeys.some((k) => mapFilter[k]); + if (anySelected && !mapFilter.aprs && selectedAprsTrackCall) { + const entry = stationMarkers.get(String(selectedAprsTrackCall)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); } + selectedAprsTrackCall = null; } - if (source && target && source !== target) { - const band = bandForHz(Number(detail?.freq_hz)); - directedMessages.push({ - source, - target, - sourceGrid: grid, - sourceType: entry.sourceType, - tsMs, - bandLabel: band?.label || null, - remote: detail?.remote || null - }); - } - } - } - for (const msg of directedMessages) { - const targetLocator = stationLocators.get(msg.target); - if (!targetLocator) continue; - if (msg.sourceGrid === targetLocator.grid) continue; - const sourceCenter = locatorToLatLon(msg.sourceGrid); - const targetCenter = locatorToLatLon(targetLocator.grid); - if (!sourceCenter || !targetCenter) continue; - const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); - const key = [msg.source, msg.target].sort().join("::"); - const prev = decodeContactPaths.get(key); - if (prev?.tsMs != null && prev.tsMs > msg.tsMs) continue; - decodeContactPaths.set(key, { - pathKey: key, - source: msg.source, - target: msg.target, - sourceGrid: msg.sourceGrid, - targetGrid: targetLocator.grid, - sourceType: msg.sourceType, - bandLabel: msg.bandLabel, - from: sourceCenter, - to: targetCenter, - tsMs: msg.tsMs, - distanceKm, - distanceText: formatDecodeContactDistance(distanceKm), - line: null, - labelMarker: null, - remote: msg.remote - }); - } - syncDecodeContactPathVisibility(); - } - function _receiverLabel(rigId) { - if (!rigId) return null; - const rig = T.serverRigs.find((r) => r.remote === rigId); - const name = T.lastRigDisplayNames[rigId] || rigId; - if (rig && rig.latitude != null && rig.longitude != null) { - const grid = latLonToMaidenhead(rig.latitude, rig.longitude); - return `${name} (${grid})`; - } - return name; - } - function renderMapQsoSummary() { - const listEl = mapEl("map-qso-summary-list"); - if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const entries = Array.from(decodeContactPaths.values()).filter((entry) => entry && Number.isFinite(entry.distanceKm) && _statsDetailPassesRigFilter(entry) && (!entry.tsMs || entry.tsMs >= cutoff)).sort((a, b) => { - const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); - if (Math.abs(distanceDelta) > 1e-3) return distanceDelta; - return Number(b.tsMs || 0) - Number(a.tsMs || 0); - }).slice(0, MAP_QSO_SUMMARY_LIMIT); - if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) { - selectedMapQsoKey = null; - } - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "map-qso-summary-empty"; - empty.textContent = "No directed FT8 or WSPR contacts match the current map history and filters."; - listEl.replaceChildren(empty); - return; - } - const fragment = document.createDocumentFragment(); - entries.forEach((entry, index) => { - const card = document.createElement("button"); - card.type = "button"; - card.className = "map-qso-card"; - card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); - card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); - card.addEventListener("click", () => { - selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; - syncDecodeContactPathVisibility(); - if (selectedMapQsoKey && entry.sourceGrid) { - mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); - } - }); - const head = document.createElement("div"); - head.className = "map-qso-card-head"; - const rank = document.createElement("span"); - rank.className = "map-qso-card-rank"; - rank.textContent = `#${index + 1}`; - head.appendChild(rank); - const distance = document.createElement("span"); - distance.className = "map-qso-card-distance"; - distance.textContent = entry.distanceText || "--"; - head.appendChild(distance); - const body = document.createElement("div"); - body.className = "map-qso-card-body"; - const pair = document.createElement("div"); - pair.className = "map-qso-card-pair"; - pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`; - body.appendChild(pair); - const meta = document.createElement("div"); - meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); - sourceType.className = "map-qso-card-pill"; - sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); - meta.appendChild(sourceType); - if (entry.bandLabel) { - const band = document.createElement("span"); - band.className = "map-qso-card-pill map-qso-card-band"; - band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); - band.textContent = entry.bandLabel; - meta.appendChild(band); - } - const ageText = formatTimeAgo(Number(entry.tsMs)); - if (ageText) { - const age = document.createElement("span"); - age.className = "map-qso-card-pill"; - age.textContent = ageText; - meta.appendChild(age); - } - const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; - if (rxLabel) { - const rx = document.createElement("span"); - rx.className = "map-qso-card-pill map-qso-card-rx"; - rx.textContent = rxLabel; - meta.appendChild(rx); - } - body.appendChild(meta); - const grids = document.createElement("div"); - grids.className = "map-qso-card-grids"; - grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`; - body.appendChild(grids); - card.appendChild(head); - card.appendChild(body); - fragment.appendChild(card); - }); - listEl.replaceChildren(fragment); - } - function renderMapSignalSummary() { - const listEl = mapEl("map-signal-summary-list"); - if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const bestByStation = /* @__PURE__ */ new Map(); - for (const entry of locatorMarkers.values()) { - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; - if (!(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (!Number.isFinite(detail?.snr_db)) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - if (detail.ts_ms && detail.ts_ms < cutoff) continue; - const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (!station) continue; - const snrDb = Number(detail.snr_db); - const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; - const prev = bestByStation.get(station); - if (!prev || snrDb > prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { - bestByStation.set(station, { - station, - snrDb, - tsMs, - grid: entry.grid, - sourceType: entry.sourceType, - bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, - remote: detail?.remote || null - }); - } - } - } - const entries = Array.from(bestByStation.values()).sort((a, b) => { - const delta = b.snrDb - a.snrDb; - if (Math.abs(delta) > 1e-3) return delta; - return b.tsMs - a.tsMs; - }).slice(0, MAP_QSO_SUMMARY_LIMIT); - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "map-qso-summary-empty"; - empty.textContent = "No decoded signals with SNR data in the current map history."; - listEl.replaceChildren(empty); - return; - } - const fragment = document.createDocumentFragment(); - entries.forEach((entry, index) => { - const card = document.createElement("button"); - card.type = "button"; - card.className = "map-qso-card"; - if (entry.grid) { - card.addEventListener("click", () => { - mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); - }); - } - const head = document.createElement("div"); - head.className = "map-qso-card-head"; - const rank = document.createElement("span"); - rank.className = "map-qso-card-rank"; - rank.textContent = `#${index + 1}`; - head.appendChild(rank); - const snr = document.createElement("span"); - snr.className = "map-qso-card-distance"; - snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; - head.appendChild(snr); - const body = document.createElement("div"); - body.className = "map-qso-card-body"; - const pair = document.createElement("div"); - pair.className = "map-qso-card-pair"; - pair.textContent = entry.station; - body.appendChild(pair); - const meta = document.createElement("div"); - meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); - sourceType.className = "map-qso-card-pill"; - sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); - meta.appendChild(sourceType); - if (entry.bandLabel) { - const band = document.createElement("span"); - band.className = "map-qso-card-pill map-qso-card-band"; - band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); - band.textContent = entry.bandLabel; - meta.appendChild(band); - } - const ageText = formatTimeAgo(Number(entry.tsMs)); - if (ageText) { - const age = document.createElement("span"); - age.className = "map-qso-card-pill"; - age.textContent = ageText; - meta.appendChild(age); - } - const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; - if (rxLabel) { - const rx = document.createElement("span"); - rx.className = "map-qso-card-pill map-qso-card-rx"; - rx.textContent = rxLabel; - meta.appendChild(rx); - } - body.appendChild(meta); - const grids = document.createElement("div"); - grids.className = "map-qso-card-grids"; - grids.textContent = entry.grid || "--"; - body.appendChild(grids); - card.appendChild(head); - card.appendChild(body); - fragment.appendChild(card); - }); - listEl.replaceChildren(fragment); - } - function renderMapWeakSignalSummary() { - const listEl = mapEl("map-weak-signal-summary-list"); - if (!listEl) return; - const cutoff = _statsHistoryCutoffMs(); - const worstByStation = /* @__PURE__ */ new Map(); - for (const entry of locatorMarkers.values()) { - if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; - if (!(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (!Number.isFinite(detail?.snr_db)) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - if (detail.ts_ms && detail.ts_ms < cutoff) continue; - const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (!station) continue; - const snrDb = Number(detail.snr_db); - const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; - const prev = worstByStation.get(station); - if (!prev || snrDb < prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { - worstByStation.set(station, { - station, - snrDb, - tsMs, - grid: entry.grid, - sourceType: entry.sourceType, - bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, - remote: detail?.remote || null - }); - } - } - } - const entries = Array.from(worstByStation.values()).sort((a, b) => { - const delta = a.snrDb - b.snrDb; - if (Math.abs(delta) > 1e-3) return delta; - return b.tsMs - a.tsMs; - }).slice(0, MAP_QSO_SUMMARY_LIMIT); - if (entries.length === 0) { - const empty = document.createElement("div"); - empty.className = "map-qso-summary-empty"; - empty.textContent = "No decoded signals with SNR data in the current map history."; - listEl.replaceChildren(empty); - return; - } - const fragment = document.createDocumentFragment(); - entries.forEach((entry, index) => { - const card = document.createElement("button"); - card.type = "button"; - card.className = "map-qso-card"; - if (entry.grid) { - card.addEventListener("click", () => { - mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); - }); - } - const head = document.createElement("div"); - head.className = "map-qso-card-head"; - const rank = document.createElement("span"); - rank.className = "map-qso-card-rank"; - rank.textContent = `#${index + 1}`; - head.appendChild(rank); - const snr = document.createElement("span"); - snr.className = "map-qso-card-distance"; - snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; - head.appendChild(snr); - const body = document.createElement("div"); - body.className = "map-qso-card-body"; - const pair = document.createElement("div"); - pair.className = "map-qso-card-pair"; - pair.textContent = entry.station; - body.appendChild(pair); - const meta = document.createElement("div"); - meta.className = "map-qso-card-meta"; - const sourceType = document.createElement("span"); - sourceType.className = "map-qso-card-pill"; - sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); - meta.appendChild(sourceType); - if (entry.bandLabel) { - const band = document.createElement("span"); - band.className = "map-qso-card-pill map-qso-card-band"; - band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); - band.textContent = entry.bandLabel; - meta.appendChild(band); - } - const ageText = formatTimeAgo(Number(entry.tsMs)); - if (ageText) { - const age = document.createElement("span"); - age.className = "map-qso-card-pill"; - age.textContent = ageText; - meta.appendChild(age); - } - const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; - if (rxLabel) { - const rx = document.createElement("span"); - rx.className = "map-qso-card-pill map-qso-card-rx"; - rx.textContent = rxLabel; - meta.appendChild(rx); - } - body.appendChild(meta); - const grids = document.createElement("div"); - grids.className = "map-qso-card-grids"; - grids.textContent = entry.grid || "--"; - body.appendChild(grids); - card.appendChild(head); - card.appendChild(body); - fragment.appendChild(card); - }); - listEl.replaceChildren(fragment); - } - let statsRigFilter = ""; - let statsHistoryLimitMinutes = 1440; - const statsDecodeLog = []; - const STATS_LOG_MAX = 5e4; - const STATS_TYPE_COLORS = { - ft8: "#4fc3f7", - ft4: "#81c784", - ft2: "#aed581", - wspr: "#ffb74d", - aprs: "#ce93d8", - hf_aprs: "#ba68c8", - ais: "#90a4ae", - vdes: "#78909c", - cw: "#fff176" - }; - const STATS_DX_BUCKETS = [ - { label: "0–500 km", min: 0, max: 500 }, - { label: "500–1k", min: 500, max: 1e3 }, - { label: "1k–2k", min: 1e3, max: 2e3 }, - { label: "2k–5k", min: 2e3, max: 5e3 }, - { label: "5k–10k", min: 5e3, max: 1e4 }, - { label: "10k+ km", min: 1e4, max: Infinity } - ]; - function _statsHistoryCutoffMs() { - return Date.now() - statsHistoryLimitMinutes * 60 * 1e3; - } - function _statsDetailPassesRigFilter(detail) { - if (!statsRigFilter) return true; - if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); - return detail?.remote === statsRigFilter; - } - function updateStatsRigFilter() { - const el = mapEl("stats-rig-filter"); - if (!el) return; - const prev = el.value; - while (el.options.length > 1) el.remove(1); - for (const id of T.lastRigIds) { - const opt = document.createElement("option"); - opt.value = id; - opt.textContent = T.lastRigDisplayNames[id] || id; - el.appendChild(opt); - } - if (prev && T.lastRigIds.includes(prev)) { - el.value = prev; - } else { - el.value = ""; - statsRigFilter = ""; - } - } - function statsRecordDecode(type, remote, tsMs) { - statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); - if (statsDecodeLog.length > STATS_LOG_MAX) { - statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); - } - } - function _statsFilteredLog() { - const cutoff = _statsHistoryCutoffMs(); - return statsDecodeLog.filter((e) => { - if (e.ts_ms < cutoff) return false; - if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false; - return true; - }); - } - function renderStatsCounters() { - const cutoff = _statsHistoryCutoffMs(); - const log = _statsFilteredLog(); - const totalDecodes = log.length; - const uniqueStations = /* @__PURE__ */ new Set(); - const uniqueGrids = /* @__PURE__ */ new Set(); - for (const entry of locatorMarkers.values()) { - if (!entry || !(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (detail?.ts_ms && detail.ts_ms < cutoff) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); - if (station) uniqueStations.add(station); - } - if (entry.grid) { - const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some( - (d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d) - ); - if (hasVisible) uniqueGrids.add(entry.grid); - } - } - const rateWindow = Date.now() - 6e4; - const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; - const setEl = (id, val) => { - const el = mapEl(id); - if (el) el.textContent = String(val); - }; - setEl("stats-total-decodes", totalDecodes.toLocaleString()); - setEl("stats-unique-stations", uniqueStations.size.toLocaleString()); - setEl("stats-unique-grids", uniqueGrids.size.toLocaleString()); - setEl("stats-decode-rate", recentCount.toLocaleString()); - } - function _renderBarChart(containerId, data, emptyMsg) { - const el = mapEl(containerId); - if (!el) return; - if (!data || data.length === 0 || data.every((d) => d.count === 0)) { - el.replaceChildren(); - const empty = document.createElement("div"); - empty.className = "stats-bar-empty"; - empty.textContent = emptyMsg || "No data available."; - el.appendChild(empty); - return; - } - const maxVal = Math.max(1, ...data.map((d) => d.count)); - const fragment = document.createDocumentFragment(); - for (const item of data) { - const row = document.createElement("div"); - row.className = "stats-bar-row"; - const label = document.createElement("span"); - label.className = "stats-bar-label"; - label.textContent = item.label; - row.appendChild(label); - const track = document.createElement("div"); - track.className = "stats-bar-track"; - const fill = document.createElement("div"); - fill.className = "stats-bar-fill"; - fill.style.width = `${item.count / maxVal * 100}%`; - fill.style.background = item.color || "var(--accent-green)"; - track.appendChild(fill); - row.appendChild(track); - const count = document.createElement("span"); - count.className = "stats-bar-count"; - count.textContent = item.count.toLocaleString(); - row.appendChild(count); - fragment.appendChild(row); - } - el.replaceChildren(fragment); - } - function renderStatsDecodeTypes() { - const log = _statsFilteredLog(); - const counts = {}; - for (const e of log) { - counts[e.type] = (counts[e.type] || 0) + 1; - } - const data = Object.entries(counts).map(([type, count]) => ({ - label: type.toUpperCase(), - count, - color: STATS_TYPE_COLORS[type] || "#aaa" - })).sort((a, b) => b.count - a.count); - _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history."); - } - function renderStatsBandActivity() { - const cutoff = _statsHistoryCutoffMs(); - const bandCounts = {}; - for (const entry of locatorMarkers.values()) { - if (!entry || !(entry.stationDetails instanceof Map)) continue; - for (const detail of entry.stationDetails.values()) { - if (detail?.ts_ms && detail.ts_ms < cutoff) continue; - if (!_statsDetailPassesRigFilter(detail)) continue; - if (!Number.isFinite(detail?.freq_hz)) continue; - const band = bandForHz(Number(detail.freq_hz)); - if (band) { - bandCounts[band.label] = (bandCounts[band.label] || 0) + 1; - } - } - } - const data = Object.entries(bandCounts).map(([label, count]) => ({ - label, - count, - color: locatorBandChipColor(label) - })).sort((a, b) => b.count - a.count); - _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history."); - } - function renderStatsRigCompare() { - const section = mapEl("stats-rig-compare-section"); - if (!section) return; - if (T.lastRigIds.length < 2) { - section.style.display = "none"; - return; - } - section.style.display = ""; - const cutoff = _statsHistoryCutoffMs(); - const rigCounts = {}; - for (const e of statsDecodeLog) { - if (e.ts_ms < cutoff) continue; - const rid = e.remote || "unknown"; - rigCounts[rid] = (rigCounts[rid] || 0) + 1; - } - const data = Object.entries(rigCounts).map(([rid, count]) => ({ - label: T.lastRigDisplayNames[rid] || rid, - count, - color: "var(--accent-green)" - })).sort((a, b) => b.count - a.count); - _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver."); - } - function renderStatsDxHistogram() { - const cutoff = _statsHistoryCutoffMs(); - const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 })); - for (const entry of decodeContactPaths.values()) { - if (!entry || !Number.isFinite(entry.distanceKm)) continue; - if (entry.tsMs && entry.tsMs < cutoff) continue; - if (!_statsDetailPassesRigFilter(entry)) continue; - const km = entry.distanceKm; - if (km == null) continue; - for (const b of buckets) { - if (km >= b.min && km < b.max) { - b.count++; - break; - } - } - } - const data = buckets.map((b) => ({ - label: b.label, - count: b.count, - color: "#4fc3f7" - })); - _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history."); - } - let _statsRenderPending = false; - let _statsControlsWired = false; - function _wireStatsControls() { - if (_statsControlsWired) return; - const rigEl = mapEl("stats-rig-filter"); - const histEl = mapEl("stats-history-limit"); - if (!rigEl && !histEl) return; - _statsControlsWired = true; - if (rigEl) { - rigEl.addEventListener("change", () => { - statsRigFilter = rigEl.value; - scheduleStatsRender(); - }); - } - if (histEl) { - histEl.value = String(statsHistoryLimitMinutes); - histEl.addEventListener("change", () => { - statsHistoryLimitMinutes = Number(histEl.value) || 1440; - scheduleStatsRender(); - }); - } - } - function scheduleStatsRender() { - if (_statsRenderPending) return; - _statsRenderPending = true; - requestAnimationFrame(() => { - _statsRenderPending = false; - _wireStatsControls(); - renderStatsCounters(); - renderStatsDecodeTypes(); - renderStatsBandActivity(); - renderStatsRigCompare(); - renderStatsDxHistogram(); - renderMapQsoSummary(); - renderMapSignalSummary(); - renderMapWeakSignalSummary(); - }); - } - function buildBookmarkLocatorPopupHtml(grid, bookmarks) { - const list = Array.isArray(bookmarks) ? bookmarks : []; - const rows = list.map((bm) => { - const title = escapeMapHtml(String(bm.name || "Bookmark")); - const freq = escapeMapHtml(formatFreqForHumans(Number(bm.freq_hz))); - const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; - return `${title} ${freq}${mode}`; - }).join("
"); - return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); - } - mapWindow.syncBookmarkMapLocators = function(bookmarks) { - const list = Array.isArray(bookmarks) ? bookmarks : []; - const grouped = /* @__PURE__ */ new Map(); - for (const bm of list) { - const grid = String(bm?.locator || "").trim().toUpperCase(); - if (!grid) continue; - const bounds = maidenheadToBounds(grid); - if (!bounds) continue; - const key = `bookmark:${grid}`; - const bucket = grouped.get(key); - if (bucket) { - bucket.bookmarks.push(bm); - } else { - grouped.set(key, { grid, bounds, bookmarks: [bm] }); - } - } - for (const [key, entry] of locatorMarkers.entries()) { - if (!key.startsWith("bookmark:")) continue; - if (!grouped.has(key)) { - if (entry && entry.marker) { - if (entry.marker === selectedLocatorMarker) { - setSelectedLocatorMarker(null); - clearMapRadioPath(); + if (anySelected && !mapFilter.ais && selectedAisTrackMmsi) { + const entry = aisMarkers.get(String(selectedAisTrackMmsi)); + if (entry && entry.track && aprsMap && aprsMap.hasLayer(entry.track)) { + entry.track.removeFrom(aprsMap); } - if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); - mapMarkers.delete(entry.marker); + selectedAisTrackMmsi = null; } - locatorMarkers.delete(key); - } - } - for (const [key, next] of grouped.entries()) { - const existing = locatorMarkers.get(key); - const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); - const bandMeta = collectBandMeta(next.bookmarks.map((bm) => Number(bm?.freq_hz))); - if (existing) { - existing.grid = next.grid; - existing.bounds = next.bounds; - existing.bookmarks = next.bookmarks; - existing.sourceType = "bookmark"; - existing.bandMeta = bandMeta; - if (existing.marker) { - existing.marker.setBounds(next.bounds); - existing.marker.setStyle(locatorStyleForEntry(existing, next.bookmarks.length)); - existing.marker.setPopupContent(popupHtml); - sendLocatorOverlayToBack(existing.marker); - assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); + } else if (kind === "band") { + if (mapLocatorFilter.bands.has(key)) { + mapLocatorFilter.bands.delete(key); + } else { + mapLocatorFilter.bands.add(key); } - continue; - } - const entry = { - marker: null, - grid: next.grid, - bounds: next.bounds, - bookmarks: next.bookmarks, - sourceType: "bookmark", - bandMeta - }; - locatorMarkers.set(key, entry); - if (aprsMap) { - entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)).addTo(aprsMap).bindPopup(popupHtml); - entry.marker.__trxType = "bookmark"; - sendLocatorOverlayToBack(entry.marker); - assignLocatorMarkerMeta(entry.marker, entry.sourceType ?? "bookmark", entry.bandMeta); - mapMarkers.add(entry.marker); - } - } - rebuildMapLocatorFilters(); - applyMapFilter(); - }; - mapWindow.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { - if (!Array.isArray(grids) || grids.length === 0) return; - const markerType = type === "wspr" ? "wspr" : type === "ft4" ? "ft4" : type === "ft2" ? "ft2" : "ft8"; - const msgRigId = details?.rig_id || T.lastActiveRigId; - const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; - const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; - const locatorDetails = /* @__PURE__ */ new Map(); - if (Array.isArray(details?.locator_details)) { - for (const locatorDetail of details.locator_details) { - const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); - if (!grid) continue; - locatorDetails.set(grid, locatorDetail); - } - } - for (const grid of unique) { - const bounds = maidenheadToBounds(grid); - if (!bounds) continue; - const locatorDetail = locatorDetails.get(grid); - const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() ? String(locatorDetail.source).trim().toUpperCase() : ""; - const targetId = locatorDetail?.target && String(locatorDetail.target).trim() ? String(locatorDetail.target).trim().toUpperCase() : ""; - const detailStationId = sourceId || stationId; - const detailEntry = { - station: detailStationId || null, - source: sourceId || null, - target: targetId || null, - ts_ms: details && Number.isFinite(details.ts_ms) ? Number(details.ts_ms) : null, - snr_db: details && Number.isFinite(details.snr_db) ? Number(details.snr_db) : null, - dt_s: details && Number.isFinite(details.dt_s) ? Number(details.dt_s) : null, - freq_hz: details && Number.isFinite(details.freq_hz) ? Number(details.freq_hz) : null, - message: String(details?.message || message || "").trim() || null, - remote: msgRigId || null, - remotes: new Set(msgRigId ? [msgRigId] : []) - }; - const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; - const key = `${markerType}:${grid}`; - const existing = locatorMarkers.get(key); - if (existing) { - existing.grid = grid; - if (!(existing.allStationDetails instanceof Map)) { - existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) : /* @__PURE__ */ new Map(); - } - const prevDetail = existing.allStationDetails.get(detailKey); - const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : /* @__PURE__ */ new Set(); - if (msgRigId) mergedRemotes.add(msgRigId); - existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); - existing.sourceType = markerType; - if (msgRigId) { - if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); - existing.rigIds.add(msgRigId); - } - pruneLocatorEntry(key, existing, mapHistoryCutoffMs()); - if (existing.marker) sendLocatorOverlayToBack(existing.marker); - scheduleDecodeMapMaintenance(); - continue; - } - const allStationDetails = /* @__PURE__ */ new Map(); - allStationDetails.set(detailKey, { ...detailEntry }); - const entry = { - marker: null, - grid, - stations: /* @__PURE__ */ new Set(), - stationDetails: /* @__PURE__ */ new Map(), - allStationDetails, - sourceType: markerType, - bandMeta: /* @__PURE__ */ new Map(), - rigIds: new Set(msgRigId ? [msgRigId] : []) - }; - locatorMarkers.set(key, entry); - pruneLocatorEntry(key, entry, mapHistoryCutoffMs()); - if (entry.marker) sendLocatorOverlayToBack(entry.marker); - } - scheduleDecodeMapMaintenance(); - }; - document.querySelectorAll(".sub-tab-bar").forEach((bar) => { - bar.addEventListener("click", (e) => { - const btn = e.target?.closest(".sub-tab[data-subtab]"); - if (!btn) return; - bar.querySelectorAll(".sub-tab").forEach((t) => { - t.classList.remove("active"); - }); - btn.classList.add("active"); - const parent = bar.parentElement; - if (!parent) return; - parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); - const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); - if (nextPanel) nextPanel.style.display = ""; - if (btn.dataset.subtab === "cw" && mapWindow.refreshCwTonePicker) { - requestAnimationFrame(() => { - if (mapWindow.refreshCwTonePicker) mapWindow.refreshCwTonePicker(); - }); - } - if (btn.dataset.subtab !== "sat" && typeof mapWindow.clearSatPredictionDom === "function") { - mapWindow.clearSatPredictionDom(); } + rebuildMapLocatorFilters(); + applyMapFilter(); }); - }); - window.addEventListener("resize", () => { - const mapTab = mapEl("tab-map"); - if (!mapTab || mapTab.style.display === "none") return; - sizeAprsMapToViewport(); - }); - function flushDeferredDecodeMapSync() { - if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return; - T.decodeMapSyncPending = false; - scheduleUiFrameJob("decode-map-maintenance", () => { + } + const mapRigFilterEl = mapEl("map-rig-filter"); + if (mapRigFilterEl) { + mapRigFilterEl.addEventListener("change", () => { + mapRigFilter = mapRigFilterEl.value; + applyMapFilter(); + }); + } + if (mapSearchEl) { + mapSearchEl.value = mapSearchFilter; + mapSearchEl.addEventListener("input", () => { + mapSearchFilter = String(mapSearchEl.value || "").trim(); + applyMapFilter(); + }); + } + if (mapHistoryLimitEl) { + mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); + mapHistoryLimitEl.addEventListener("change", () => { + mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value)); + mapHistoryLimitEl.value = String(mapHistoryLimitMinutes); + saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes); pruneMapHistory(); }); } - function autoInitIfVisible() { - const panel = mapEl("tab-map"); - if (panel.style.display === "none") return; - if (typeof L === "undefined") { - window.setTimeout(autoInitIfVisible, 50); + if (mapP2pPathsToggleEl) { + updateMapP2pPathsToggle(); + mapP2pPathsToggleEl.addEventListener("click", () => { + mapP2pRadioPathsEnabled = !mapP2pRadioPathsEnabled; + saveSetting("mapP2pRadioPathsEnabled", mapP2pRadioPathsEnabled); + updateMapP2pPathsToggle(); + if (!mapP2pRadioPathsEnabled) clearMapRadioPath(); + }); + } + if (mapContactPathsToggleEl) { + updateMapContactPathsToggle(); + mapContactPathsToggleEl.addEventListener("click", () => { + mapDecodeContactPathsEnabled = !mapDecodeContactPathsEnabled; + saveSetting("mapDecodeContactPathsEnabled", mapDecodeContactPathsEnabled); + updateMapContactPathsToggle(); + syncDecodeContactPathVisibility(); + }); + } + if (fullscreenBtn) { + fullscreenBtn.addEventListener("click", () => { + void toggleMapFullscreen(); + }); + updateMapFullscreenButton(); + } + applyMapOverlayPanelVisibility(); + updateMapOverlayToggleButton(); + if (overlayToggleBtn) { + overlayToggleBtn.addEventListener("click", () => { + mapOverlayPanelVisible = !mapOverlayPanelVisible; + saveSetting("mapOverlayPanelVisible", mapOverlayPanelVisible); + applyMapOverlayPanelVisibility(); + updateMapOverlayToggleButton(); + }); + } + if (!mapFullscreenListenerBound) { + const onFullscreenChange = () => { + updateMapFullscreenButton(); + sizeAprsMapToViewport(); + }; + document.addEventListener("fullscreenchange", onFullscreenChange); + document.addEventListener("webkitfullscreenchange", onFullscreenChange); + mapFullscreenListenerBound = true; + } + if (!mapHistoryPruneTimer) { + mapHistoryPruneTimer = setInterval(() => { + pruneMapHistory(); + }, 60 * 1e3); + } + rebuildMapLocatorFilters(); + } + function sizeAprsMapToViewport() { + const mapContainer = mapEl("aprs-map"); + const stage = mapStageEl(); + if (mapIsFullscreen() && stage) { + const isFake = stage.classList.contains("map-fake-fullscreen"); + const stageHeight = isFake ? window.innerHeight : stage.clientHeight || stage.getBoundingClientRect().height; + const target2 = Math.max(260, Math.floor(stageHeight)); + mapContainer.style.height = `${target2}px`; + if (aprsMap) aprsMap.invalidateSize(); + return; + } + const mapRect = mapContainer.getBoundingClientRect(); + const width = mapContainer.clientWidth || mapRect.width; + const footer = document.querySelector(".footer"); + let bottom = mapIsFullscreen() && stage ? stage.getBoundingClientRect().bottom : window.innerHeight; + if (!mapIsFullscreen() && footer) { + const fr = footer.getBoundingClientRect(); + if (fr.top > mapRect.top + 50) bottom = fr.top; + } + const available = Math.max(0, Math.floor(bottom - mapRect.top - 8)); + const widthDriven = width > 0 ? Math.floor(width / 1.55) : available; + const viewportCap = mapIsFullscreen() ? Math.floor(window.innerHeight * 0.9) : Math.floor(window.innerHeight * 0.75); + const minHeight = Math.min(260, available); + const target = Math.max(minHeight, Math.min(available, viewportCap, widthDriven)); + mapContainer.style.height = `${target}px`; + if (aprsMap) aprsMap.invalidateSize(); + } + function aprsSymbolIcon(symbolTable, symbolCode) { + if (!symbolTable || !symbolCode) return null; + const table = symbolTable === "/" ? "primary" : "alternate"; + return L.divIcon({ + className: "", + html: `
${escapeMapHtml(symbolCode)}
`, + iconSize: [24, 24], + iconAnchor: [12, 12], + popupAnchor: [0, -12] + }); + } + mapWindow.navigateToAprsMap = function(lat, lon) { + T._activeTab = "map"; + document.querySelectorAll(".tab-bar .tab").forEach((t) => { + t.classList.remove("active"); + }); + const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); + if (mapTabBtn) mapTabBtn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = mapEl("tab-map"); + if (mapPanel) mapPanel.style.display = ""; + initAprsMap(); + sizeAprsMapToViewport(); + if (aprsMap) { + requestAnimationFrame(() => { + requestAnimationFrame(() => { + sizeAprsMapToViewport(); + aprsMap?.invalidateSize(); + aprsMap?.setView([lat, lon], 13); + }); + }); + } + }; + mapWindow.navigateToMapLocator = function(grid, preferredType = null) { + const normalizedGrid = String(grid || "").trim().toUpperCase(); + if (!/^[A-R]{2}\d{2}(?:[A-X]{2})?$/.test(normalizedGrid)) return false; + T._activeTab = "map"; + document.querySelectorAll(".tab-bar .tab").forEach((t) => { + t.classList.remove("active"); + }); + const mapTabBtn = document.querySelector(".tab-bar .tab[data-tab='map']"); + if (mapTabBtn) mapTabBtn.classList.add("active"); + document.querySelectorAll(".tab-panel").forEach((p) => p.style.display = "none"); + const mapPanel = mapEl("tab-map"); + if (mapPanel) mapPanel.style.display = ""; + initAprsMap(); + sizeAprsMapToViewport(); + if (!aprsMap) return false; + const pref = preferredType === "wspr" ? "wspr" : preferredType === "ft4" ? "ft4" : preferredType === "ft2" ? "ft2" : preferredType === "ft8" ? "ft8" : null; + const keys = pref ? [`${pref}:${normalizedGrid}`, `ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`] : [`ft8:${normalizedGrid}`, `ft4:${normalizedGrid}`, `ft2:${normalizedGrid}`, `wspr:${normalizedGrid}`, `bookmark:${normalizedGrid}`]; + let entry = null; + for (const key of keys) { + entry = locatorMarkers.get(key); + if (entry?.marker) break; + } + if (!entry?.marker) return false; + if (pref && Object.prototype.hasOwnProperty.call(mapFilter, pref) && !mapFilter[pref]) { + mapFilter[pref] = true; + rebuildMapLocatorFilters(); + applyMapFilter(); + } + const marker = entry.marker; + if (!aprsMap.hasLayer(marker)) { + marker.addTo(aprsMap); + sendLocatorOverlayToBack(marker); + } + const center = locatorMarkerCenter(marker); + const focusMarker = () => { + if (!aprsMap || !marker) return; + sizeAprsMapToViewport(); + aprsMap.invalidateSize(); + if (center) { + const targetZoom = Math.max(aprsMap.getZoom() || 0, 7); + aprsMap.setView([center.lat, center.lon], targetZoom); + if (marker.__trxType !== "bookmark") { + const fEntry = locatorEntryForMarker(marker); + const sourceType = marker.__trxType; + const fColor = fEntry ? locatorStyleForEntry(fEntry, locatorEntryCount(fEntry)).color ?? locatorFilterColor(sourceType) : locatorFilterColor(sourceType); + setMapRadioPathTo(center.lat, center.lon, fColor, "locator-radio-path", marker.__trxRigIds); + } + } + setSelectedLocatorMarker(marker); + if (typeof marker.openPopup === "function") marker.openPopup(); + }; + focusMarker(); + requestAnimationFrame(() => { + requestAnimationFrame(focusMarker); + }); + return true; + }; + function buildReceiverPopupHtml(rigIds) { + const call = T.serverCallsign || T.ownerCallsign || "Receiver"; + let meta = ""; + if (T.serverVersion) { + meta = `trx-server v${escapeMapHtml(T.serverVersion)}`; + if (T.serverBuildDate) meta += ` · ${escapeMapHtml(T.serverBuildDate)}`; + } + let rows = ""; + if (T.ownerCallsign && T.ownerCallsign !== T.serverCallsign) { + rows += `Owner${escapeMapHtml(T.ownerCallsign)}`; + } + const rigList = rigIds ? Array.from(rigIds) : []; + const rigSet = rigList.length ? new Set(rigList) : null; + const firstRig = rigSet ? T.serverRigs.find((r) => !!r.remote && rigSet.has(r.remote)) : null; + const popupLat = firstRig ? firstRig.latitude : T.serverLat; + const popupLon = firstRig ? firstRig.longitude : T.serverLon; + if (popupLat != null && popupLon != null) { + const grid = latLonToMaidenhead(popupLat, popupLon); + rows += `QTH${popupLat.toFixed(5)}, ${popupLon.toFixed(5)} (${escapeMapHtml(grid)})`; + } + const rigsToShow = rigSet ? T.serverRigs.filter((r) => !!r.remote && rigSet.has(r.remote)) : T.serverRigs; + for (const rig of rigsToShow) { + const name = rig.display_name || `${rig.manufacturer} ${rig.model}`.trim(); + const active = rig.remote === T.serverActiveRigId ? ` active` : ""; + rows += `Rig${escapeMapHtml(name)}${active}`; + } + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + `
`; + } + function buildAprsPopupHtml(call, lat, lon, info, pkt) { + const age = pkt?._tsMs ? formatTimeAgo(pkt._tsMs) : pkt?._ts || null; + const distKm = T.serverLat != null && T.serverLon != null ? haversineKm(T.serverLat, T.serverLon, lat, lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const path = pkt?.path || null; + const type = pkt?.type || null; + const meta = [age, distStr].filter(Boolean).join(" · "); + let rows = ""; + if (type) rows += `Type${escapeMapHtml(type)}`; + if (path) rows += `Path${escapeMapHtml(path)}`; + if (lat != null && lon != null) + rows += `Pos${lat.toFixed(5)}, ${lon.toFixed(5)}`; + return `
${escapeMapHtml(call)}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${escapeMapHtml(info)}
` : "") + `
`; + } + function buildAisPopupHtml(msg) { + const age = msg?._tsMs ? formatTimeAgo(msg._tsMs) : null; + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const meta = [age, distStr, msg?.channel ? `AIS ${escapeMapHtml(msg.channel)}` : null].filter(Boolean).join(" · "); + let rows = ""; + rows += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; + rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; + if (distStr) rows += `Range${distStr} from TRX`; + if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; + if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; + if (msg?.heading_deg != null) rows += `HDG${Number(msg.heading_deg).toFixed(0)}°`; + if (msg?.nav_status != null) rows += `Nav${escapeMapHtml(String(msg.nav_status))}`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · "); + const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`); + const vesselUrl = mapWindow.buildAisVesselUrl ? mapWindow.buildAisVesselUrl(msg?.mmsi) : null; + const vesselTitle = vesselUrl ? `${vesselLabel}` : vesselLabel; + return `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + } + function buildVdesPopupHtml(msg) { + const age = msg.ts_ms == null ? "" : formatTimeAgo(msg.ts_ms); + const distKm = T.serverLat != null && T.serverLon != null && msg?.lat != null && msg?.lon != null ? haversineKm(T.serverLat, T.serverLon, msg.lat, msg.lon) : null; + const distStr = distKm != null ? distKm < 1 ? `${Math.round(distKm * 1e3)} m` : `${distKm.toFixed(1)} km` : null; + const meta = [ + age, + distStr, + msg?.message_label ? escapeMapHtml(msg.message_label) : null, + Number.isFinite(msg?.link_id) ? `LID ${Number(msg.link_id)}` : null + ].filter(Boolean).join(" · "); + let rows = ""; + if (distStr) rows += `Range${distStr} from TRX`; + rows += `Type${escapeMapHtml(String(msg?.message_type ?? "--"))}`; + if (Number.isFinite(msg?.source_id)) rows += `Source${escapeMapHtml(String(msg.source_id))}`; + if (Number.isFinite(msg?.destination_id)) rows += `Dest${escapeMapHtml(String(msg.destination_id))}`; + if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; + if (Number.isFinite(msg?.sync_score)) rows += `Sync${(Number(msg.sync_score) * 100).toFixed(0)}%`; + if (msg?.fec_state) rows += `FEC${escapeMapHtml(String(msg.fec_state))}`; + const info = [ + msg?.vessel_name, + msg?.callsign, + msg?.destination, + msg?.payload_preview + ].filter(Boolean).map(escapeMapHtml).join(" · "); + const title = escapeMapHtml(msg?.vessel_name || msg?.callsign || "VDES Position"); + return `
${title}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + `
`; + } + function aprsPositionsEqual(a, b) { + if (!a || !b) return false; + const aLat = Array.isArray(a) ? a[0] : a.lat; + const aLon = Array.isArray(a) ? a[1] : a.lon; + const bLat = Array.isArray(b) ? b[0] : b.lat; + const bLon = Array.isArray(b) ? b[1] : b.lon; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + } + function aisPositionsEqual(a, b) { + if (!a || !b) return false; + const aLat = Array.isArray(a) ? a[0] : a.lat; + const aLon = Array.isArray(a) ? a[1] : a.lon; + const bLat = Array.isArray(b) ? b[0] : b.lat; + const bLon = Array.isArray(b) ? b[1] : b.lon; + return Math.abs(aLat - bLat) < 1e-6 && Math.abs(aLon - bLon) < 1e-6; + } + function vdesMarkerKey(msg) { + if (Number.isFinite(msg?.source_id)) return `src:${Number(msg.source_id)}`; + if (Number.isFinite(msg?.mmsi) && Number(msg.mmsi) > 0) return `mmsi:${Number(msg.mmsi)}`; + if (msg?.lat != null && msg?.lon != null) { + return `pos:${Number(msg.lat).toFixed(4)}:${Number(msg.lon).toFixed(4)}:${Number(msg?.message_type ?? 0)}`; + } + return null; + } + function _aprsAddMarkerToMap(call, entry) { + if (!aprsMap || entry.lat == null || entry.lon == null) return; + refreshAprsTrack(call, entry); + const icon = aprsSymbolIcon(entry.symbolTable ?? "", entry.symbolCode ?? ""); + const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); + const marker = icon ? L.marker([entry.lat, entry.lon], { icon }).addTo(aprsMap).bindPopup(popupContent) : L.circleMarker([entry.lat, entry.lon], { + radius: 6, + color: "#00d17f", + fillColor: "#00d17f", + fillOpacity: 0.8 + }).addTo(aprsMap).bindPopup(popupContent); + marker.__trxType = "aprs"; + marker.__trxRigIds = entry.rigIds || /* @__PURE__ */ new Set(); + marker._aprsCall = call; + entry.marker = marker; + mapMarkers.add(marker); + } + mapWindow.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { + const nextPoint = [lat, lon]; + const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now(); + const msgRigId = pkt?.rig_id || T.lastActiveRigId; + const existing = stationMarkers.get(call); + if (existing) { + existing.pkt = pkt; + existing.lat = lat; + existing.lon = lon; + existing.info = info; + existing.symbolTable = symbolTable; + existing.symbolCode = symbolCode; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; + const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; + if (!aprsPositionsEqual(prevPoint, nextPoint)) { + existing.trackHistory.push({ lat, lon, tsMs }); + } else if (prevPoint) { + prevPoint.tsMs = tsMs; + } + pruneAprsEntry(call, existing, mapHistoryCutoffMs()); + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + existing.marker.setLatLng([lat, lon]); + existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); + } + } else { + const entry = { + marker: null, + track: null, + trackHistory: [{ lat, lon, tsMs }], + trackPoints: [nextPoint], + type: "aprs", + pkt, + lat, + lon, + info, + symbolTable, + symbolCode, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + stationMarkers.set(call, entry); + pruneAprsEntry(call, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry); + if (aprsMap) scheduleDecodeMapMaintenance(); + } + }; + function syncSelectedAisTrackVisibility() { + if (!aprsMap) return; + const map = aprsMap; + const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; + aisMarkers.forEach((entry, key) => { + const track = entry?.track; + if (!track) return; + const shouldShow = !!selectedKey && selectedKey === String(key) && !!mapFilter.ais; + const onMap = map.hasLayer(track); + if (shouldShow && !onMap) { + track.addTo(map); + } + if (!shouldShow && onMap) { + track.removeFrom(map); + } + }); + } + function getAisAccentColor() { + return getComputedStyle(document.documentElement).getPropertyValue("--accent-green").trim() || "#c24b1a"; + } + function aisMarkerOptionsFromMessage(msg) { + const color = getAisAccentColor(); + return { + heading: msg?.heading_deg, + course: msg?.cog_deg, + speed: msg?.sog_knots, + color, + outline: "#00000055", + size: 22 + }; + } + function createAisMarker(lat, lon, msg) { + if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") { + return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg)); + } + const color = getAisAccentColor(); + return L.circleMarker([lat, lon], { + radius: 6, + color, + fillColor: color, + fillOpacity: 0.82 + }); + } + function updateAisMarker(marker, msg, popupHtml) { + if (!marker || msg.lat == null || msg.lon == null) return; + marker.setLatLng([msg.lat, msg.lon]); + if (typeof marker.setAisState === "function") { + marker.setAisState(aisMarkerOptionsFromMessage(msg)); + } + if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") { + const color = getAisAccentColor(); + marker.setRadius(6); + marker.setStyle({ + color, + fillColor: color, + fillOpacity: 0.84 + }); + } + marker.setPopupContent(popupHtml); + } + function refreshAisMarkerColors() { + const color = getAisAccentColor(); + aisMarkers.forEach((entry) => { + if (entry.marker) { + if (typeof entry.marker.setAisState === "function") { + entry.marker.setAisState(aisMarkerOptionsFromMessage(entry.msg || {})); + } else if (typeof entry.marker.setStyle === "function") { + entry.marker.setStyle({ color, fillColor: color }); + } + } + if (entry.track && typeof entry.track.setStyle === "function") { + entry.track.setStyle({ color }); + } + }); + } + mapWindow.aisMapAddVessel = function(msg) { + if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return; + const key = String(msg.mmsi); + const popupHtml = buildAisPopupHtml(msg); + const nextPoint = [msg.lat, msg.lon]; + const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now(); + const msgRigId = msg?.rig_id || T.lastActiveRigId; + const existing = aisMarkers.get(key); + if (existing) { + existing.msg = msg; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!Array.isArray(existing.trackHistory)) existing.trackHistory = []; + const prevPoint = existing.trackHistory[existing.trackHistory.length - 1]; + if (!aisPositionsEqual(prevPoint, nextPoint)) { + existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs }); + } else if (prevPoint) { + prevPoint.tsMs = tsMs; + } + pruneAisEntry(key, existing, mapHistoryCutoffMs()); + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + updateAisMarker(existing.marker, msg, popupHtml); + } + return; + } + const entry = { + marker: null, + track: null, + trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }], + trackPoints: [nextPoint], + msg, + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + aisMarkers.set(key, entry); + pruneAisEntry(key, entry, mapHistoryCutoffMs()); + if (entry.visibleInHistoryWindow) ensureAisMarker(key, entry); + scheduleDecodeMapMaintenance(); + }; + mapWindow.vdesMapAddPoint = function(msg) { + if (msg == null || msg.lat == null || msg.lon == null) return; + const key = vdesMarkerKey(msg); + if (!key) return; + const popupHtml = buildVdesPopupHtml(msg); + const visible = Number.isFinite(Number(msg?._tsMs)) && Number(msg._tsMs) >= mapHistoryCutoffMs(); + const msgRigId = msg?.rig_id || T.lastActiveRigId; + const existing = vdesMarkers.get(key); + if (existing) { + existing.msg = msg; + existing.visibleInHistoryWindow = visible; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + if (!visible) { + if (!C.decodeHistoryMapRenderingDeferred()) { + setRetainedMapMarkerVisible(existing.marker, false); + } else { + C.markDecodeMapSyncPending(); + } return; } - initAprsMap(); - requestAnimationFrame(() => requestAnimationFrame(sizeAprsMapToViewport)); + if (!C.decodeHistoryMapRenderingDeferred()) { + ensureVdesMarker(key, existing); + setRetainedMapMarkerVisible(existing.marker, true); + } else { + C.markDecodeMapSyncPending(); + } + if (aprsMap && existing.marker && !T.decodeHistoryReplayActive) { + existing.marker.setLatLng([msg.lat, msg.lon]); + existing.marker.setPopupContent(popupHtml); + } + return; } - modules.map = { - initAprsMap, - sizeAprsMapToViewport, - syncAprsReceiverMarker, - updateMapRigFilter, - updateStatsRigFilter, - statsRecordDecode, - scheduleStatsRender, - get aprsMap() { - return aprsMap; - }, - get stationMarkers() { - return stationMarkers; - }, - get locatorMarkers() { - return locatorMarkers; - }, - get aisMarkers() { - return aisMarkers; - }, - get vdesMarkers() { - return vdesMarkers; - }, - get decodeContactPaths() { - return decodeContactPaths; - }, - pruneMapHistory, - aprsSymbolIcon, - buildAprsPopupHtml, - buildAisPopupHtml, - buildVdesPopupHtml, - ensureAprsMarker, - ensureAisMarker, - ensureVdesMarker, - ensureDecodeLocatorMarker, - aprsPositionsEqual, - aisPositionsEqual, - refreshAprsTrack, - refreshAisTrack, - updateAisMarker, - createAisMarker, - getAisAccentColor, - refreshAisMarkerColors, - setMapRadioPathTo, - buildReceiverPopupHtml, - rebuildDecodeContactPaths, - syncDecodeContactPathVisibility, - scheduleDecodeMapMaintenance, - renderMapLocatorLegend, - rebuildMapLocatorFilters, - renderMapQsoSummary, - renderMapSignalSummary, - renderMapWeakSignalSummary, - vdesMarkerKey, - aisMarkerOptionsFromMessage, - materializeBufferedMapLayers, - flushDeferredDecodeMapSync, - bandForHz, - reverseGeocodeLocation + const entry = { + marker: null, + msg, + visibleInHistoryWindow: visible, + rigIds: new Set(msgRigId ? [msgRigId] : []) }; - autoInitIfVisible(); - })(); + vdesMarkers.set(key, entry); + if (!visible) return; + if (!C.decodeHistoryMapRenderingDeferred()) { + ensureVdesMarker(key, entry); + setRetainedMapMarkerVisible(entry.marker, true); + } else { + C.markDecodeMapSyncPending(); + } + if (aprsMap && entry.marker && !T.decodeHistoryReplayActive) { + entry.marker.setPopupContent(popupHtml); + } + scheduleDecodeMapMaintenance(); + }; + let reverseGeocodeLastKey = null; + function reverseGeocodeLocation(lat, lon, grid) { + const key = `${lat.toFixed(4)},${lon.toFixed(4)}`; + if (key === reverseGeocodeLastKey) return; + reverseGeocodeLastKey = key; + const url = `https://nominatim.openstreetmap.org/reverse?lat=${encodeURIComponent(lat)}&lon=${encodeURIComponent(lon)}&format=json&zoom=10&accept-language=en`; + fetch(url, { headers: { "User-Agent": "trx-rs" } }).then((r) => r.ok ? r.json() : Promise.reject(new Error(`Reverse geocode failed: ${r.status}`))).then((data) => { + const addr = data?.address; + if (!addr) return; + const city = addr.city || addr.town || addr.village || addr.hamlet || addr.municipality || addr.county || ""; + const country = addr.country || ""; + if (!city && !country) return; + const label = city && country ? `${city}, ${country}` : city || country; + T.lastCityLabel = label; + if (T.locationSubtitle) { + T.locationSubtitle.textContent = `Location: ${grid} · ${label}`; + } + C.updateDocumentTitle?.(); + }).catch(() => { + }); + } + function maidenheadToBounds(grid) { + if (!grid || grid.length < 4) return null; + const g = grid.toUpperCase(); + const A = "A".charCodeAt(0); + const fieldLon = (g.charCodeAt(0) - A) * 20 - 180; + const fieldLat = (g.charCodeAt(1) - A) * 10 - 90; + const squareLon = parseInt(g.charAt(2), 10) * 2; + const squareLat = parseInt(g.charAt(3), 10); + let lon = fieldLon + squareLon; + let lat = fieldLat + squareLat; + let lonSpan = 2; + let latSpan = 1; + if (g.length >= 6) { + const subLon = (g.charCodeAt(4) - A) * (5 / 60); + const subLat = (g.charCodeAt(5) - A) * (2.5 / 60); + lon += subLon; + lat += subLat; + lonSpan = 5 / 60; + latSpan = 2.5 / 60; + } + return [ + [lat, lon], + [lat + latSpan, lon + lonSpan] + ]; + } + function applyMapFilter() { + if (!aprsMap) return; + const map = aprsMap; + const sourceKeys = Object.keys(DEFAULT_MAP_SOURCE_FILTER); + const noneSelected = sourceKeys.every((k) => !mapFilter[k]); + mapMarkers.forEach((marker) => { + const type = marker.__trxType; + const sourceVisible = noneSelected ? type == null || DEFAULT_MAP_SOURCE_FILTER[type] : type == null || !!mapFilter[type]; + const rigVisible = !mapRigFilter || marker.__trxType === "bookmark" || marker.__trxRigIds instanceof Set && marker.__trxRigIds.has(mapRigFilter); + const visible = marker.__trxHistoryVisible !== false && markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && sourceVisible && rigVisible; + const onMap = map.hasLayer(marker); + if (visible && !onMap) { + marker.addTo(map); + sendLocatorOverlayToBack(marker); + } + if (!visible && onMap) marker.removeFrom(map); + }); + syncSelectedAisTrackVisibility(); + syncDecodeContactPathVisibility(); + } + function updateMapContactPathsToggle() { + const btn = mapEl("map-contact-paths-toggle"); + if (!btn) return; + btn.textContent = mapDecodeContactPathsEnabled ? "Contact Paths On" : "Contact Paths Off"; + btn.classList.toggle("is-active", mapDecodeContactPathsEnabled); + } + function updateMapP2pPathsToggle() { + const btn = mapEl("map-p2p-paths-toggle"); + if (!btn) return; + btn.textContent = mapP2pRadioPathsEnabled ? "TRX Paths On" : "TRX Paths Off"; + btn.classList.toggle("is-active", mapP2pRadioPathsEnabled); + } + function scheduleDecodeMapMaintenance() { + if (C.decodeHistoryMapRenderingDeferred()) { + C.markDecodeMapSyncPending(); + return; + } + scheduleUiFrameJob("decode-map-maintenance", () => { + rebuildDecodeContactPaths(); + rebuildMapLocatorFilters(); + applyMapFilter(); + }); + } + function formatDecodeLocatorTime(tsMs) { + if (!Number.isFinite(tsMs)) return "--:--:--"; + return new Date(tsMs).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit" + }); + } + function formatMapPopupFreq(hz) { + if (!Number.isFinite(hz)) return "--"; + const value = Number(hz); + if (value >= 1e9) return `${(value / 1e9).toFixed(6).replace(/\.?0+$/, "")} GHz`; + if (value >= 1e6) return `${(value / 1e6).toFixed(6).replace(/\.?0+$/, "")} MHz`; + if (value >= 1e3) return `${(value / 1e3).toFixed(3).replace(/\.?0+$/, "")} kHz`; + return `${Math.round(value)} Hz`; + } + function buildDecodeLocatorTooltipHtml(grid, entry, type) { + const details = entry?.stationDetails instanceof Map ? Array.from(entry.stationDetails.values()) : []; + details.sort((a, b) => Number(b?.ts_ms || 0) - Number(a?.ts_ms || 0)); + const title = type === "wspr" ? "WSPR" : "FT8"; + const rows = details.map((detail) => { + const station = escapeMapHtml(String(detail?.source || detail?.station || detail?.target || "Unknown")); + const freq = formatMapPopupFreq(Number(detail?.freq_hz)); + const meta = [ + detail?.target ? `to ${escapeMapHtml(String(detail.target))}` : null, + Number.isFinite(detail?.snr_db) ? `${Number(detail.snr_db).toFixed(1)} dB` : null, + Number.isFinite(detail?.dt_s) ? `dt ${Number(detail.dt_s).toFixed(2)}` : null, + escapeMapHtml(freq) + ].filter(Boolean).join(" · "); + const remoteIds = detail?.remotes instanceof Set && detail.remotes.size > 0 ? Array.from(detail.remotes) : detail?.remote ? [detail.remote] : []; + const rxHtml = remoteIds.map((rid) => { + const label = _receiverLabel(rid); + return label ? `
${escapeMapHtml(label)}
` : ""; + }).filter(Boolean).join(""); + const message = detail?.message ? `
${escapeMapHtml(String(detail.message))}
` : ""; + return `
${station}${escapeMapHtml(formatDecodeLocatorTime(Number(detail?.ts_ms)))}
` + (meta ? `
${meta}
` : "") + rxHtml + message + `
`; + }).join(""); + const count = Math.max( + 1, + details.length, + entry?.stations instanceof Set ? entry.stations.size : 0 + ); + return `
${escapeMapHtml(grid)}
${title} · ${count} station${count === 1 ? "" : "s"}
` + rows + `
`; + } + function rebuildDecodeContactPaths() { + clearDecodeContactPaths(); + const stationLocators = /* @__PURE__ */ new Map(); + const directedMessages = []; + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + const grid = String(entry.grid || "").trim().toUpperCase(); + if (!grid || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + const source = String(detail?.source || detail?.station || "").trim().toUpperCase(); + const target = String(detail?.target || "").trim().toUpperCase(); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + if (source) { + const prev = stationLocators.get(source); + if (!prev || tsMs >= prev.tsMs) { + stationLocators.set(source, { grid, tsMs }); + } + } + if (source && target && source !== target) { + const band = bandForHz(Number(detail?.freq_hz)); + directedMessages.push({ + source, + target, + sourceGrid: grid, + sourceType: entry.sourceType, + tsMs, + bandLabel: band?.label || null, + remote: detail?.remote || null + }); + } + } + } + for (const msg of directedMessages) { + const targetLocator = stationLocators.get(msg.target); + if (!targetLocator) continue; + if (msg.sourceGrid === targetLocator.grid) continue; + const sourceCenter = locatorToLatLon(msg.sourceGrid); + const targetCenter = locatorToLatLon(targetLocator.grid); + if (!sourceCenter || !targetCenter) continue; + const distanceKm = haversineKm(sourceCenter.lat, sourceCenter.lon, targetCenter.lat, targetCenter.lon); + const key = [msg.source, msg.target].sort().join("::"); + const prev = decodeContactPaths.get(key); + if (prev?.tsMs != null && prev.tsMs > msg.tsMs) continue; + decodeContactPaths.set(key, { + pathKey: key, + source: msg.source, + target: msg.target, + sourceGrid: msg.sourceGrid, + targetGrid: targetLocator.grid, + sourceType: msg.sourceType, + bandLabel: msg.bandLabel, + from: sourceCenter, + to: targetCenter, + tsMs: msg.tsMs, + distanceKm, + distanceText: formatDecodeContactDistance(distanceKm), + line: null, + labelMarker: null, + remote: msg.remote + }); + } + syncDecodeContactPathVisibility(); + } + function _receiverLabel(rigId) { + if (!rigId) return null; + const rig = T.serverRigs.find((r) => r.remote === rigId); + const name = T.lastRigDisplayNames[rigId] || rigId; + if (rig && rig.latitude != null && rig.longitude != null) { + const grid = latLonToMaidenhead(rig.latitude, rig.longitude); + return `${name} (${grid})`; + } + return name; + } + function renderMapQsoSummary() { + const listEl = mapEl("map-qso-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const entries = Array.from(decodeContactPaths.values()).filter((entry) => entry && Number.isFinite(entry.distanceKm) && _statsDetailPassesRigFilter(entry) && (!entry.tsMs || entry.tsMs >= cutoff)).sort((a, b) => { + const distanceDelta = Number(b.distanceKm) - Number(a.distanceKm); + if (Math.abs(distanceDelta) > 1e-3) return distanceDelta; + return Number(b.tsMs || 0) - Number(a.tsMs || 0); + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (selectedMapQsoKey && !entries.some((entry) => entry.pathKey === selectedMapQsoKey)) { + selectedMapQsoKey = null; + } + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No directed FT8 or WSPR contacts match the current map history and filters."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + card.classList.toggle("is-selected", entry.pathKey === selectedMapQsoKey); + card.setAttribute("aria-pressed", entry.pathKey === selectedMapQsoKey ? "true" : "false"); + card.addEventListener("click", () => { + selectedMapQsoKey = selectedMapQsoKey === entry.pathKey ? null : entry.pathKey ?? null; + syncDecodeContactPathVisibility(); + if (selectedMapQsoKey && entry.sourceGrid) { + mapWindow.navigateToMapLocator?.(entry.sourceGrid, entry.sourceType); + } + }); + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const distance = document.createElement("span"); + distance.className = "map-qso-card-distance"; + distance.textContent = entry.distanceText || "--"; + head.appendChild(distance); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + function renderMapSignalSummary() { + const listEl = mapEl("map-signal-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const bestByStation = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + if (!(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (!Number.isFinite(detail?.snr_db)) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (detail.ts_ms && detail.ts_ms < cutoff) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (!station) continue; + const snrDb = Number(detail.snr_db); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + const prev = bestByStation.get(station); + if (!prev || snrDb > prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { + bestByStation.set(station, { + station, + snrDb, + tsMs, + grid: entry.grid, + sourceType: entry.sourceType, + bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, + remote: detail?.remote || null + }); + } + } + } + const entries = Array.from(bestByStation.values()).sort((a, b) => { + const delta = b.snrDb - a.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No decoded signals with SNR data in the current map history."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + if (entry.grid) { + card.addEventListener("click", () => { + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + }); + } + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const snr = document.createElement("span"); + snr.className = "map-qso-card-distance"; + snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; + head.appendChild(snr); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = entry.station; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = entry.grid || "--"; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + function renderMapWeakSignalSummary() { + const listEl = mapEl("map-weak-signal-summary-list"); + if (!listEl) return; + const cutoff = _statsHistoryCutoffMs(); + const worstByStation = /* @__PURE__ */ new Map(); + for (const entry of locatorMarkers.values()) { + if (!entry || entry.sourceType !== "ft8" && entry.sourceType !== "ft4" && entry.sourceType !== "ft2" && entry.sourceType !== "wspr") continue; + if (!(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (!Number.isFinite(detail?.snr_db)) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (detail.ts_ms && detail.ts_ms < cutoff) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (!station) continue; + const snrDb = Number(detail.snr_db); + const tsMs = Number.isFinite(detail?.ts_ms) ? Number(detail.ts_ms) : 0; + const prev = worstByStation.get(station); + if (!prev || snrDb < prev.snrDb || snrDb === prev.snrDb && tsMs > prev.tsMs) { + worstByStation.set(station, { + station, + snrDb, + tsMs, + grid: entry.grid, + sourceType: entry.sourceType, + bandLabel: bandForHz(Number(detail?.freq_hz))?.label || null, + remote: detail?.remote || null + }); + } + } + } + const entries = Array.from(worstByStation.values()).sort((a, b) => { + const delta = a.snrDb - b.snrDb; + if (Math.abs(delta) > 1e-3) return delta; + return b.tsMs - a.tsMs; + }).slice(0, MAP_QSO_SUMMARY_LIMIT); + if (entries.length === 0) { + const empty = document.createElement("div"); + empty.className = "map-qso-summary-empty"; + empty.textContent = "No decoded signals with SNR data in the current map history."; + listEl.replaceChildren(empty); + return; + } + const fragment = document.createDocumentFragment(); + entries.forEach((entry, index) => { + const card = document.createElement("button"); + card.type = "button"; + card.className = "map-qso-card"; + if (entry.grid) { + card.addEventListener("click", () => { + mapWindow.navigateToMapLocator?.(entry.grid ?? "", entry.sourceType); + }); + } + const head = document.createElement("div"); + head.className = "map-qso-card-head"; + const rank = document.createElement("span"); + rank.className = "map-qso-card-rank"; + rank.textContent = `#${index + 1}`; + head.appendChild(rank); + const snr = document.createElement("span"); + snr.className = "map-qso-card-distance"; + snr.textContent = `${entry.snrDb >= 0 ? "+" : ""}${entry.snrDb.toFixed(0)} dB`; + head.appendChild(snr); + const body = document.createElement("div"); + body.className = "map-qso-card-body"; + const pair = document.createElement("div"); + pair.className = "map-qso-card-pair"; + pair.textContent = entry.station; + body.appendChild(pair); + const meta = document.createElement("div"); + meta.className = "map-qso-card-meta"; + const sourceType = document.createElement("span"); + sourceType.className = "map-qso-card-pill"; + sourceType.textContent = String(entry.sourceType || "ft8").toUpperCase(); + meta.appendChild(sourceType); + if (entry.bandLabel) { + const band = document.createElement("span"); + band.className = "map-qso-card-pill map-qso-card-band"; + band.style.setProperty("--band-color", locatorBandChipColor(entry.bandLabel)); + band.textContent = entry.bandLabel; + meta.appendChild(band); + } + const ageText = formatTimeAgo(Number(entry.tsMs)); + if (ageText) { + const age = document.createElement("span"); + age.className = "map-qso-card-pill"; + age.textContent = ageText; + meta.appendChild(age); + } + const rxLabel = entry.remote ? _receiverLabel(entry.remote) : null; + if (rxLabel) { + const rx = document.createElement("span"); + rx.className = "map-qso-card-pill map-qso-card-rx"; + rx.textContent = rxLabel; + meta.appendChild(rx); + } + body.appendChild(meta); + const grids = document.createElement("div"); + grids.className = "map-qso-card-grids"; + grids.textContent = entry.grid || "--"; + body.appendChild(grids); + card.appendChild(head); + card.appendChild(body); + fragment.appendChild(card); + }); + listEl.replaceChildren(fragment); + } + let statsRigFilter = ""; + let statsHistoryLimitMinutes = 1440; + const statsDecodeLog = []; + const STATS_LOG_MAX = 5e4; + const STATS_TYPE_COLORS = { + ft8: "#4fc3f7", + ft4: "#81c784", + ft2: "#aed581", + wspr: "#ffb74d", + aprs: "#ce93d8", + hf_aprs: "#ba68c8", + ais: "#90a4ae", + vdes: "#78909c", + cw: "#fff176" + }; + const STATS_DX_BUCKETS = [ + { label: "0–500 km", min: 0, max: 500 }, + { label: "500–1k", min: 500, max: 1e3 }, + { label: "1k–2k", min: 1e3, max: 2e3 }, + { label: "2k–5k", min: 2e3, max: 5e3 }, + { label: "5k–10k", min: 5e3, max: 1e4 }, + { label: "10k+ km", min: 1e4, max: Infinity } + ]; + function _statsHistoryCutoffMs() { + return Date.now() - statsHistoryLimitMinutes * 60 * 1e3; + } + function _statsDetailPassesRigFilter(detail) { + if (!statsRigFilter) return true; + if (detail?.remotes instanceof Set) return detail.remotes.has(statsRigFilter); + return detail?.remote === statsRigFilter; + } + function updateStatsRigFilter() { + const el = mapEl("stats-rig-filter"); + if (!el) return; + const prev = el.value; + while (el.options.length > 1) el.remove(1); + for (const id of T.lastRigIds) { + const opt = document.createElement("option"); + opt.value = id; + opt.textContent = T.lastRigDisplayNames[id] || id; + el.appendChild(opt); + } + if (prev && T.lastRigIds.includes(prev)) { + el.value = prev; + } else { + el.value = ""; + statsRigFilter = ""; + } + } + function statsRecordDecode(type, remote, tsMs) { + statsDecodeLog.push({ type: String(type || "unknown"), ts_ms: tsMs || Date.now(), remote: remote || null }); + if (statsDecodeLog.length > STATS_LOG_MAX) { + statsDecodeLog.splice(0, statsDecodeLog.length - STATS_LOG_MAX); + } + } + function _statsFilteredLog() { + const cutoff = _statsHistoryCutoffMs(); + return statsDecodeLog.filter((e) => { + if (e.ts_ms < cutoff) return false; + if (statsRigFilter && e.remote && e.remote !== statsRigFilter) return false; + return true; + }); + } + function renderStatsCounters() { + const cutoff = _statsHistoryCutoffMs(); + const log = _statsFilteredLog(); + const totalDecodes = log.length; + const uniqueStations = /* @__PURE__ */ new Set(); + const uniqueGrids = /* @__PURE__ */ new Set(); + for (const entry of locatorMarkers.values()) { + if (!entry || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (detail?.ts_ms && detail.ts_ms < cutoff) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + const station = String(detail?.source || detail?.station || "").trim().toUpperCase(); + if (station) uniqueStations.add(station); + } + if (entry.grid) { + const hasVisible = entry.stationDetails instanceof Map && Array.from(entry.stationDetails.values()).some( + (d) => (!d.ts_ms || d.ts_ms >= cutoff) && _statsDetailPassesRigFilter(d) + ); + if (hasVisible) uniqueGrids.add(entry.grid); + } + } + const rateWindow = Date.now() - 6e4; + const recentCount = log.filter((e) => e.ts_ms >= rateWindow).length; + const setEl = (id, val) => { + const el = mapEl(id); + if (el) el.textContent = String(val); + }; + setEl("stats-total-decodes", totalDecodes.toLocaleString()); + setEl("stats-unique-stations", uniqueStations.size.toLocaleString()); + setEl("stats-unique-grids", uniqueGrids.size.toLocaleString()); + setEl("stats-decode-rate", recentCount.toLocaleString()); + } + function _renderBarChart(containerId, data, emptyMsg) { + const el = mapEl(containerId); + if (!el) return; + if (!data || data.length === 0 || data.every((d) => d.count === 0)) { + el.replaceChildren(); + const empty = document.createElement("div"); + empty.className = "stats-bar-empty"; + empty.textContent = emptyMsg || "No data available."; + el.appendChild(empty); + return; + } + const maxVal = Math.max(1, ...data.map((d) => d.count)); + const fragment = document.createDocumentFragment(); + for (const item of data) { + const row = document.createElement("div"); + row.className = "stats-bar-row"; + const label = document.createElement("span"); + label.className = "stats-bar-label"; + label.textContent = item.label; + row.appendChild(label); + const track = document.createElement("div"); + track.className = "stats-bar-track"; + const fill = document.createElement("div"); + fill.className = "stats-bar-fill"; + fill.style.width = `${item.count / maxVal * 100}%`; + fill.style.background = item.color || "var(--accent-green)"; + track.appendChild(fill); + row.appendChild(track); + const count = document.createElement("span"); + count.className = "stats-bar-count"; + count.textContent = item.count.toLocaleString(); + row.appendChild(count); + fragment.appendChild(row); + } + el.replaceChildren(fragment); + } + function renderStatsDecodeTypes() { + const log = _statsFilteredLog(); + const counts = {}; + for (const e of log) { + counts[e.type] = (counts[e.type] || 0) + 1; + } + const data = Object.entries(counts).map(([type, count]) => ({ + label: type.toUpperCase(), + count, + color: STATS_TYPE_COLORS[type] || "#aaa" + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-decode-type-bars", data, "No decoded signals in the current history."); + } + function renderStatsBandActivity() { + const cutoff = _statsHistoryCutoffMs(); + const bandCounts = {}; + for (const entry of locatorMarkers.values()) { + if (!entry || !(entry.stationDetails instanceof Map)) continue; + for (const detail of entry.stationDetails.values()) { + if (detail?.ts_ms && detail.ts_ms < cutoff) continue; + if (!_statsDetailPassesRigFilter(detail)) continue; + if (!Number.isFinite(detail?.freq_hz)) continue; + const band = bandForHz(Number(detail.freq_hz)); + if (band) { + bandCounts[band.label] = (bandCounts[band.label] || 0) + 1; + } + } + } + const data = Object.entries(bandCounts).map(([label, count]) => ({ + label, + count, + color: locatorBandChipColor(label) + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-band-activity-bars", data, "No band activity data in the current history."); + } + function renderStatsRigCompare() { + const section = mapEl("stats-rig-compare-section"); + if (!section) return; + if (T.lastRigIds.length < 2) { + section.style.display = "none"; + return; + } + section.style.display = ""; + const cutoff = _statsHistoryCutoffMs(); + const rigCounts = {}; + for (const e of statsDecodeLog) { + if (e.ts_ms < cutoff) continue; + const rid = e.remote || "unknown"; + rigCounts[rid] = (rigCounts[rid] || 0) + 1; + } + const data = Object.entries(rigCounts).map(([rid, count]) => ({ + label: T.lastRigDisplayNames[rid] || rid, + count, + color: "var(--accent-green)" + })).sort((a, b) => b.count - a.count); + _renderBarChart("stats-rig-compare-bars", data, "No decode data per receiver."); + } + function renderStatsDxHistogram() { + const cutoff = _statsHistoryCutoffMs(); + const buckets = STATS_DX_BUCKETS.map((b) => ({ ...b, count: 0 })); + for (const entry of decodeContactPaths.values()) { + if (!entry || !Number.isFinite(entry.distanceKm)) continue; + if (entry.tsMs && entry.tsMs < cutoff) continue; + if (!_statsDetailPassesRigFilter(entry)) continue; + const km = entry.distanceKm; + if (km == null) continue; + for (const b of buckets) { + if (km >= b.min && km < b.max) { + b.count++; + break; + } + } + } + const data = buckets.map((b) => ({ + label: b.label, + count: b.count, + color: "#4fc3f7" + })); + _renderBarChart("stats-dx-histogram-bars", data, "No directed contact paths in the current history."); + } + let _statsRenderPending = false; + let _statsControlsWired = false; + function _wireStatsControls() { + if (_statsControlsWired) return; + const rigEl = mapEl("stats-rig-filter"); + const histEl = mapEl("stats-history-limit"); + if (!rigEl && !histEl) return; + _statsControlsWired = true; + if (rigEl) { + rigEl.addEventListener("change", () => { + statsRigFilter = rigEl.value; + scheduleStatsRender(); + }); + } + if (histEl) { + histEl.value = String(statsHistoryLimitMinutes); + histEl.addEventListener("change", () => { + statsHistoryLimitMinutes = Number(histEl.value) || 1440; + scheduleStatsRender(); + }); + } + } + function scheduleStatsRender() { + if (_statsRenderPending) return; + _statsRenderPending = true; + requestAnimationFrame(() => { + _statsRenderPending = false; + _wireStatsControls(); + renderStatsCounters(); + renderStatsDecodeTypes(); + renderStatsBandActivity(); + renderStatsRigCompare(); + renderStatsDxHistogram(); + renderMapQsoSummary(); + renderMapSignalSummary(); + renderMapWeakSignalSummary(); + }); + } + function buildBookmarkLocatorPopupHtml(grid, bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const rows = list.map((bm) => { + const title = escapeMapHtml(String(bm.name || "Bookmark")); + const freq = escapeMapHtml(formatFreqForHumans(Number(bm.freq_hz))); + const mode = bm.mode ? ` · ${escapeMapHtml(String(bm.mode))}` : ""; + return `${title} ${freq}${mode}`; + }).join("
"); + return `${escapeMapHtml(grid)}
Bookmarks: ${list.length || 1}` + (rows ? `
${rows}` : ""); + } + mapWindow.syncBookmarkMapLocators = function(bookmarks) { + const list = Array.isArray(bookmarks) ? bookmarks : []; + const grouped = /* @__PURE__ */ new Map(); + for (const bm of list) { + const grid = String(bm?.locator || "").trim().toUpperCase(); + if (!grid) continue; + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const key = `bookmark:${grid}`; + const bucket = grouped.get(key); + if (bucket) { + bucket.bookmarks.push(bm); + } else { + grouped.set(key, { grid, bounds, bookmarks: [bm] }); + } + } + for (const [key, entry] of locatorMarkers.entries()) { + if (!key.startsWith("bookmark:")) continue; + if (!grouped.has(key)) { + if (entry && entry.marker) { + if (entry.marker === selectedLocatorMarker) { + setSelectedLocatorMarker(null); + clearMapRadioPath(); + } + if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap); + mapMarkers.delete(entry.marker); + } + locatorMarkers.delete(key); + } + } + for (const [key, next] of grouped.entries()) { + const existing = locatorMarkers.get(key); + const popupHtml = buildBookmarkLocatorPopupHtml(next.grid, next.bookmarks); + const bandMeta = collectBandMeta(next.bookmarks.map((bm) => Number(bm?.freq_hz))); + if (existing) { + existing.grid = next.grid; + existing.bounds = next.bounds; + existing.bookmarks = next.bookmarks; + existing.sourceType = "bookmark"; + existing.bandMeta = bandMeta; + if (existing.marker) { + existing.marker.setBounds(next.bounds); + existing.marker.setStyle(locatorStyleForEntry(existing, next.bookmarks.length)); + existing.marker.setPopupContent(popupHtml); + sendLocatorOverlayToBack(existing.marker); + assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta); + } + continue; + } + const entry = { + marker: null, + grid: next.grid, + bounds: next.bounds, + bookmarks: next.bookmarks, + sourceType: "bookmark", + bandMeta + }; + locatorMarkers.set(key, entry); + if (aprsMap) { + entry.marker = L.rectangle(next.bounds, locatorStyleForEntry(entry, next.bookmarks.length)).addTo(aprsMap).bindPopup(popupHtml); + entry.marker.__trxType = "bookmark"; + sendLocatorOverlayToBack(entry.marker); + assignLocatorMarkerMeta(entry.marker, entry.sourceType ?? "bookmark", entry.bandMeta); + mapMarkers.add(entry.marker); + } + } + rebuildMapLocatorFilters(); + applyMapFilter(); + }; + mapWindow.mapAddLocator = function(message, grids, type = "ft8", station = null, details = null) { + if (!Array.isArray(grids) || grids.length === 0) return; + const markerType = type === "wspr" ? "wspr" : type === "ft4" ? "ft4" : type === "ft2" ? "ft2" : "ft8"; + const msgRigId = details?.rig_id || T.lastActiveRigId; + const unique = [...new Set(grids.map((g) => String(g).toUpperCase()))]; + const stationId = station && String(station).trim() ? String(station).trim().toUpperCase() : ""; + const locatorDetails = /* @__PURE__ */ new Map(); + if (Array.isArray(details?.locator_details)) { + for (const locatorDetail of details.locator_details) { + const grid = String(locatorDetail?.grid || "").trim().toUpperCase(); + if (!grid) continue; + locatorDetails.set(grid, locatorDetail); + } + } + for (const grid of unique) { + const bounds = maidenheadToBounds(grid); + if (!bounds) continue; + const locatorDetail = locatorDetails.get(grid); + const sourceId = locatorDetail?.source && String(locatorDetail.source).trim() ? String(locatorDetail.source).trim().toUpperCase() : ""; + const targetId = locatorDetail?.target && String(locatorDetail.target).trim() ? String(locatorDetail.target).trim().toUpperCase() : ""; + const detailStationId = sourceId || stationId; + const detailEntry = { + station: detailStationId || null, + source: sourceId || null, + target: targetId || null, + ts_ms: details && Number.isFinite(details.ts_ms) ? Number(details.ts_ms) : null, + snr_db: details && Number.isFinite(details.snr_db) ? Number(details.snr_db) : null, + dt_s: details && Number.isFinite(details.dt_s) ? Number(details.dt_s) : null, + freq_hz: details && Number.isFinite(details.freq_hz) ? Number(details.freq_hz) : null, + message: String(details?.message || message || "").trim() || null, + remote: msgRigId || null, + remotes: new Set(msgRigId ? [msgRigId] : []) + }; + const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`; + const key = `${markerType}:${grid}`; + const existing = locatorMarkers.get(key); + if (existing) { + existing.grid = grid; + if (!(existing.allStationDetails instanceof Map)) { + existing.allStationDetails = existing.stationDetails instanceof Map ? new Map(existing.stationDetails) : /* @__PURE__ */ new Map(); + } + const prevDetail = existing.allStationDetails.get(detailKey); + const mergedRemotes = prevDetail?.remotes instanceof Set ? new Set(prevDetail.remotes) : /* @__PURE__ */ new Set(); + if (msgRigId) mergedRemotes.add(msgRigId); + existing.allStationDetails.set(detailKey, { ...detailEntry, remotes: mergedRemotes }); + existing.sourceType = markerType; + if (msgRigId) { + if (!existing.rigIds) existing.rigIds = /* @__PURE__ */ new Set(); + existing.rigIds.add(msgRigId); + } + pruneLocatorEntry(key, existing, mapHistoryCutoffMs()); + if (existing.marker) sendLocatorOverlayToBack(existing.marker); + scheduleDecodeMapMaintenance(); + continue; + } + const allStationDetails = /* @__PURE__ */ new Map(); + allStationDetails.set(detailKey, { ...detailEntry }); + const entry = { + marker: null, + grid, + stations: /* @__PURE__ */ new Set(), + stationDetails: /* @__PURE__ */ new Map(), + allStationDetails, + sourceType: markerType, + bandMeta: /* @__PURE__ */ new Map(), + rigIds: new Set(msgRigId ? [msgRigId] : []) + }; + locatorMarkers.set(key, entry); + pruneLocatorEntry(key, entry, mapHistoryCutoffMs()); + if (entry.marker) sendLocatorOverlayToBack(entry.marker); + } + scheduleDecodeMapMaintenance(); + }; + document.querySelectorAll(".sub-tab-bar").forEach((bar) => { + bar.addEventListener("click", (e) => { + const btn = e.target?.closest(".sub-tab[data-subtab]"); + if (!btn) return; + bar.querySelectorAll(".sub-tab").forEach((t) => { + t.classList.remove("active"); + }); + btn.classList.add("active"); + const parent = bar.parentElement; + if (!parent) return; + parent.querySelectorAll(".sub-tab-panel").forEach((p) => p.style.display = "none"); + const nextPanel = parent.querySelector(`#subtab-${btn.dataset.subtab}`); + if (nextPanel) nextPanel.style.display = ""; + if (btn.dataset.subtab === "cw" && mapWindow.refreshCwTonePicker) { + requestAnimationFrame(() => { + if (mapWindow.refreshCwTonePicker) mapWindow.refreshCwTonePicker(); + }); + } + if (btn.dataset.subtab !== "sat" && typeof mapWindow.clearSatPredictionDom === "function") { + mapWindow.clearSatPredictionDom(); + } + }); + }); + window.addEventListener("resize", () => { + const mapTab = mapEl("tab-map"); + if (!mapTab || mapTab.style.display === "none") return; + sizeAprsMapToViewport(); + }); + function flushDeferredDecodeMapSync() { + if (!T.decodeMapSyncPending || T.decodeHistoryReplayActive || !aprsMap) return; + T.decodeMapSyncPending = false; + scheduleUiFrameJob("decode-map-maintenance", () => { + pruneMapHistory(); + }); + } + function autoInitIfVisible() { + const panel = mapEl("tab-map"); + if (panel.style.display === "none") return; + if (typeof L === "undefined") { + window.setTimeout(autoInitIfVisible, 50); + return; + } + initAprsMap(); + requestAnimationFrame(() => requestAnimationFrame(sizeAprsMapToViewport)); + } + modules.map = { + initAprsMap, + sizeAprsMapToViewport, + syncAprsReceiverMarker, + updateMapRigFilter, + updateStatsRigFilter, + statsRecordDecode, + scheduleStatsRender, + get aprsMap() { + return aprsMap; + }, + get stationMarkers() { + return stationMarkers; + }, + get locatorMarkers() { + return locatorMarkers; + }, + get aisMarkers() { + return aisMarkers; + }, + get vdesMarkers() { + return vdesMarkers; + }, + get decodeContactPaths() { + return decodeContactPaths; + }, + pruneMapHistory, + aprsSymbolIcon, + buildAprsPopupHtml, + buildAisPopupHtml, + buildVdesPopupHtml, + ensureAprsMarker, + ensureAisMarker, + ensureVdesMarker, + ensureDecodeLocatorMarker, + aprsPositionsEqual, + aisPositionsEqual, + refreshAprsTrack, + refreshAisTrack, + updateAisMarker, + createAisMarker, + getAisAccentColor, + refreshAisMarkerColors, + setMapRadioPathTo, + buildReceiverPopupHtml, + rebuildDecodeContactPaths, + syncDecodeContactPathVisibility, + scheduleDecodeMapMaintenance, + renderMapLocatorLegend, + rebuildMapLocatorFilters, + renderMapQsoSummary, + renderMapSignalSummary, + renderMapWeakSignalSummary, + vdesMarkerKey, + aisMarkerOptionsFromMessage, + materializeBufferedMapLayers, + flushDeferredDecodeMapSync, + bandForHz, + reverseGeocodeLocation + }; + autoInitIfVisible(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js index 7a8848a0..1f8c88eb 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat-scheduler.js @@ -1,266 +1,263 @@ -"use strict"; -(() => { - // src/plugins/sat-scheduler.ts - var satSchedulerWindow = window; - (function() { - "use strict"; - const dom = { - enabled: document.getElementById("scheduler-sat-enabled"), - pretune: document.getElementById("scheduler-sat-pretune"), - body: document.getElementById("scheduler-sat-body"), - tbody: document.getElementById("scheduler-sat-tbody"), - addBtn: document.getElementById("scheduler-sat-add-btn"), - passStatus: document.getElementById("scheduler-sat-pass-status"), - formWrap: document.getElementById("sch-sat-form-wrap"), - formTitle: document.getElementById("sch-sat-form-title"), - form: document.getElementById("sch-sat-form"), - formCancel: document.getElementById("sch-sat-form-cancel"), - preset: document.getElementById("scheduler-sat-preset"), - name: document.getElementById("scheduler-sat-name"), - norad: document.getElementById("scheduler-sat-norad"), - bookmark: document.getElementById("scheduler-sat-bookmark"), - minEl: document.getElementById("scheduler-sat-min-el"), - priority: document.getElementById("scheduler-sat-priority"), - centerHz: document.getElementById("scheduler-sat-center-hz") +// src/plugins/sat-scheduler.ts +var satSchedulerWindow = window; +(function() { + "use strict"; + const dom = { + enabled: document.getElementById("scheduler-sat-enabled"), + pretune: document.getElementById("scheduler-sat-pretune"), + body: document.getElementById("scheduler-sat-body"), + tbody: document.getElementById("scheduler-sat-tbody"), + addBtn: document.getElementById("scheduler-sat-add-btn"), + passStatus: document.getElementById("scheduler-sat-pass-status"), + formWrap: document.getElementById("sch-sat-form-wrap"), + formTitle: document.getElementById("sch-sat-form-title"), + form: document.getElementById("sch-sat-form"), + formCancel: document.getElementById("sch-sat-form-cancel"), + preset: document.getElementById("scheduler-sat-preset"), + name: document.getElementById("scheduler-sat-name"), + norad: document.getElementById("scheduler-sat-norad"), + bookmark: document.getElementById("scheduler-sat-bookmark"), + minEl: document.getElementById("scheduler-sat-min-el"), + priority: document.getElementById("scheduler-sat-priority"), + centerHz: document.getElementById("scheduler-sat-center-hz") + }; + let editIdx = null; + 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, """); + } + 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 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; + } + 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); }); - return bm ? bm.name : id; - } - function escHtml(s) { - return s.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() - }; - } - 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); + tdActions.appendChild(editBtn); + const removeBtn = document.createElement("button"); + removeBtn.className = "sch-write"; + removeBtn.type = "button"; + removeBtn.textContent = "Remove"; + removeBtn.addEventListener("click", function() { + removeEntry(idx); }); - 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() { - if (!dom.passStatus) return; - const entries = getSatelliteEntries(); - if (entries.length === 0) { - dom.passStatus.innerHTML = ""; - return; - } - const status = getStatus(); - if (status && status.active_satellite) { - dom.passStatus.innerHTML = 'PASS ACTIVE: ' + escHtml(status.active_satellite) + ""; - } else { - dom.passStatus.innerHTML = 'No satellite pass active. Predictions available in the SAT tab.'; - } + const status = getStatus(); + if (status && status.active_satellite) { + dom.passStatus.innerHTML = 'PASS ACTIVE: ' + escHtml(status.active_satellite) + ""; + } else { + dom.passStatus.innerHTML = 'No satellite pass active. Predictions available in the SAT tab.'; } - function renderBookmarkSelect(selectedId) { - const bookmarkSelect = dom.bookmark; - if (!bookmarkSelect) return; - bookmarkSelect.innerHTML = ''; - getBookmarks().forEach(function(bm) { - const opt = document.createElement("option"); - opt.value = bm.id; - opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; - if (bm.id === selectedId) opt.selected = true; - bookmarkSelect.appendChild(opt); - }); + } + function renderBookmarkSelect(selectedId) { + const bookmarkSelect = dom.bookmark; + if (!bookmarkSelect) return; + bookmarkSelect.innerHTML = ''; + getBookmarks().forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + if (bm.id === selectedId) opt.selected = true; + 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(); - sat.entries.splice(idx, 1); - renderEntries(); - markDirty(); + } + function closeForm() { + if (dom.formWrap) dom.formWrap.style.display = "none"; + editIdx = null; + } + 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) { - 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(); - } + if (isNaN(noradId) || noradId <= 0) { + satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); + dom.norad?.focus(); + return; } - function closeForm() { - if (dom.formWrap) dom.formWrap.style.display = "none"; - editIdx = null; + if (!bmId) { + satSchedulerWindow.trxUi?.notify("Select a bookmark.", { kind: "error" }); + dom.bookmark?.focus(); + return; } - 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; - } - if (isNaN(noradId) || noradId <= 0) { - satSchedulerWindow.trxUi?.notify("Enter a valid NORAD catalog number.", { kind: "error" }); - dom.norad?.focus(); - return; - } - 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 + 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 (getBridge()) { - wireEvents(); - renderSection(); + 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()) { + wireEvents(); + renderSection(); + } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js index f826701a..6238a038 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/sat.js @@ -1,482 +1,479 @@ -"use strict"; -(() => { - // src/plugins/sat.ts - var satWindow = window; - var satDom = { - status: document.getElementById("sat-status"), - liveView: document.getElementById("sat-live-view"), - historyView: document.getElementById("sat-history-view"), - predictionsView: document.getElementById("sat-predictions-view"), - liveLatest: document.getElementById("sat-live-latest"), - historyList: document.getElementById("sat-history-list"), - historyCount: document.getElementById("sat-history-count"), - filterInput: document.getElementById("sat-filter"), - sortSelect: document.getElementById("sat-sort"), - typeFilter: document.getElementById("sat-type-filter"), - lrptState: document.getElementById("sat-lrpt-state"), - viewLiveBtn: document.getElementById("sat-view-live"), - viewHistoryBtn: document.getElementById("sat-view-history"), - viewPredBtn: document.getElementById("sat-view-predictions"), - predFilter: document.getElementById("sat-pred-filter"), - predMinEl: document.getElementById("sat-pred-min-el"), - predCategory: document.getElementById("sat-pred-category"), - predCurrentList: document.getElementById("sat-pred-current-list"), - predUpcomingList: document.getElementById("sat-pred-list"), - predCurrentSec: document.getElementById("sat-pred-current-section"), - predUpcomingSec: document.getElementById("sat-pred-upcoming-section"), - predStatus: document.getElementById("sat-pred-status") - }; - var satImageHistory = []; - var SAT_MAX_IMAGES = 100; - var SAT_PRED_PAGE_SIZE = 50; - var satPredShowAll = false; - var satFilterText = ""; - var satActiveView = "live"; - var satPredData = []; - var satPredFilterText = ""; - var satPredMinEl = 0; - var satPredCategory = "all"; - var satPredSatCount = 0; - var satPredCountdownTimer = null; - function scheduleSatUi(key, job) { - if (typeof satWindow.trxScheduleUiFrameJob === "function") { - satWindow.trxScheduleUiFrameJob(key, job); - return; - } - job(); +// src/plugins/sat.ts +var satWindow = window; +var satDom = { + status: document.getElementById("sat-status"), + liveView: document.getElementById("sat-live-view"), + historyView: document.getElementById("sat-history-view"), + predictionsView: document.getElementById("sat-predictions-view"), + liveLatest: document.getElementById("sat-live-latest"), + historyList: document.getElementById("sat-history-list"), + historyCount: document.getElementById("sat-history-count"), + filterInput: document.getElementById("sat-filter"), + sortSelect: document.getElementById("sat-sort"), + typeFilter: document.getElementById("sat-type-filter"), + lrptState: document.getElementById("sat-lrpt-state"), + viewLiveBtn: document.getElementById("sat-view-live"), + viewHistoryBtn: document.getElementById("sat-view-history"), + viewPredBtn: document.getElementById("sat-view-predictions"), + predFilter: document.getElementById("sat-pred-filter"), + predMinEl: document.getElementById("sat-pred-min-el"), + predCategory: document.getElementById("sat-pred-category"), + predCurrentList: document.getElementById("sat-pred-current-list"), + predUpcomingList: document.getElementById("sat-pred-list"), + predCurrentSec: document.getElementById("sat-pred-current-section"), + predUpcomingSec: document.getElementById("sat-pred-upcoming-section"), + predStatus: document.getElementById("sat-pred-status") +}; +var satImageHistory = []; +var SAT_MAX_IMAGES = 100; +var SAT_PRED_PAGE_SIZE = 50; +var satPredShowAll = false; +var satFilterText = ""; +var satActiveView = "live"; +var satPredData = []; +var satPredFilterText = ""; +var satPredMinEl = 0; +var satPredCategory = "all"; +var satPredSatCount = 0; +var satPredCountdownTimer = null; +function scheduleSatUi(key, job) { + if (typeof satWindow.trxScheduleUiFrameJob === "function") { + satWindow.trxScheduleUiFrameJob(key, job); + return; } - function switchSatView(view) { - const leavingPredictions = satActiveView === "predictions" && view !== "predictions"; - satActiveView = view; - if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; - if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none"; - if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none"; - if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live"); - if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history"); - if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions"); - if (leavingPredictions) clearPredictionDom(); - if (view === "history") { + job(); +} +function switchSatView(view) { + const leavingPredictions = satActiveView === "predictions" && view !== "predictions"; + satActiveView = view; + if (satDom.liveView) satDom.liveView.style.display = view === "live" ? "" : "none"; + if (satDom.historyView) satDom.historyView.style.display = view === "history" ? "" : "none"; + if (satDom.predictionsView) satDom.predictionsView.style.display = view === "predictions" ? "" : "none"; + if (satDom.viewLiveBtn) satDom.viewLiveBtn.classList.toggle("sat-view-active", view === "live"); + if (satDom.viewHistoryBtn) satDom.viewHistoryBtn.classList.toggle("sat-view-active", view === "history"); + if (satDom.viewPredBtn) satDom.viewPredBtn.classList.toggle("sat-view-active", view === "predictions"); + if (leavingPredictions) clearPredictionDom(); + if (view === "history") { + renderSatHistoryTable(); + } else if (view === "predictions") { + satPredShowAll = false; + 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 = '
No images decoded yet. Enable a decoder and wait for a satellite pass.
'; + 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 = `
`; + html += `
Latest decoded image
`; + html += `
${meta.join(" · ")}
`; + if (img.path) { + html += `Download PNG`; + } + if (img.geo_bounds) { + html += ` `; + } + html += `
`; + 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 ? `PNG` : "--"; + if (img.geo_bounds) { + link += ` Map`; + } + row.innerHTML = [ + `${date} ${ts}`, + `${typeName}`, + `${satellite}`, + `${channels}`, + `${lines} ${unit}`, + `${link}` + ].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(); - } 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 = '
No images decoded yet. Enable a decoder and wait for a satellite pass.
'; - 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 = `
`; - html += `
Latest decoded image
`; - html += `
${meta.join(" · ")}
`; - if (img.path) { - html += `Download PNG`; - } - if (img.geo_bounds) { - html += ` `; - } - html += `
`; - 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 ? `PNG` : "--"; - if (img.geo_bounds) { - link += ` Map`; - } - row.innerHTML = [ - `${date} ${ts}`, - `${typeName}`, - `${satellite}`, - `${channels}`, - `${lines} ${unit}`, - `${link}` - ].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) { - satDom.status.textContent = `Receiving — ${String(msg.mcu_count ?? 0)} MCU rows decoded`; - } +} +function onServerLrptProgress(msg) { + 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)"; - addSatImage(msg, "lrpt"); - if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) { - satWindow.addSatMapOverlay(msg); - } +} +function onServerLrptImage(msg) { + if (satDom.status) satDom.status.textContent = "Image received (Meteor LRPT)"; + addSatImage(msg, "lrpt"); + if (msg.geo_bounds && msg.path && satWindow.addSatMapOverlay) { + satWindow.addSatMapOverlay(msg); } - function resetSatHistoryView() { - satImageHistory = []; - if (satDom.historyList) satDom.historyList.innerHTML = ""; - renderSatLatestCard(); - 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 = [ - `${pass.satellite}`, - `${pass.max_elevation_deg.toFixed(1)}°`, - `${formatPredTime(pass.aos_ms)}`, - `${formatPredTime(pass.los_ms)}`, - `${formatCountdown(remaining)}`, - `${dir}` - ].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 = [ - `${formatPredTime(pass.aos_ms)}`, - `${pass.satellite}`, - `${pass.max_elevation_deg.toFixed(1)}°`, - `${formatPredDuration(pass.duration_s)}`, - `${dir}` - ].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 = `Show ${hiddenCount} more passes…`; - 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); - } - }; +} +function resetSatHistoryView() { + satImageHistory = []; + if (satDom.historyList) satDom.historyList.innerHTML = ""; renderSatLatestCard(); 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 = [ + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}°`, + `${formatPredTime(pass.aos_ms)}`, + `${formatPredTime(pass.los_ms)}`, + `${formatCountdown(remaining)}`, + `${dir}` + ].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 = [ + `${formatPredTime(pass.aos_ms)}`, + `${pass.satellite}`, + `${pass.max_elevation_deg.toFixed(1)}°`, + `${formatPredDuration(pass.duration_s)}`, + `${dir}` + ].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 = `Show ${hiddenCount} more passes…`; + 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(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js index 289e8bb9..be748f6c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/scheduler.js @@ -1,1128 +1,652 @@ -"use strict"; -(() => { - // src/plugins/scheduler.ts - var schedulerWindow = window; - var wiredElements = /* @__PURE__ */ new WeakSet(); - function schedulerEl(id) { - const element = document.getElementById(id); - if (!element) throw new Error(`Missing scheduler element #${id}`); - return element; +// src/plugins/scheduler.ts +var schedulerWindow = window; +var wiredElements = /* @__PURE__ */ new WeakSet(); +function schedulerEl(id) { + const element = document.getElementById(id); + if (!element) throw new Error(`Missing scheduler element #${id}`); + return element; +} +(function() { + "use strict"; + let schedulerRole = null; + let currentRigId = null; + let currentConfig = null; + let currentSchedulerStatus = null; + let bookmarkList = []; + let statusInterval = null; + let interleaveTicker = null; + let schedulerStepPending = false; + let schEntryEditIdx = null; + let schedulerDirty = false; + function initScheduler(rigId, role) { + schedulerRole = role; + currentRigId = rigId || null; + if (currentRigId) loadScheduler(); + startStatusPolling(); + startInterleaveTicker(); } - (function() { - "use strict"; - let schedulerRole = null; - let currentRigId = null; - let currentConfig = null; - let currentSchedulerStatus = null; - let bookmarkList = []; - let statusInterval = null; - let interleaveTicker = null; - let schedulerStepPending = false; - let schEntryEditIdx = null; - let schedulerDirty = false; - function initScheduler(rigId, role) { - schedulerRole = role; - currentRigId = rigId || null; - if (currentRigId) loadScheduler(); - startStatusPolling(); - startInterleaveTicker(); + function destroyScheduler() { + if (statusInterval) { + clearInterval(statusInterval); + statusInterval = null; } - function destroyScheduler() { - if (statusInterval) { - clearInterval(statusInterval); - statusInterval = null; - } - if (interleaveTicker) { - clearInterval(interleaveTicker); - interleaveTicker = null; - } + if (interleaveTicker) { + clearInterval(interleaveTicker); + interleaveTicker = null; } - function setSchedulerRig(rigId) { - const nextRigId = rigId || null; - if (nextRigId === currentRigId) return; - currentRigId = nextRigId; + } + function setSchedulerRig(rigId) { + const nextRigId = rigId || null; + if (nextRigId === currentRigId) return; + currentRigId = nextRigId; + renderSchedulerInterleaveStatus(); + if (!currentRigId) return; + loadScheduler(); + pollStatus(); + } + function apiGetScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiPutScheduler(rigId, config) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiDeleteScheduler(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId), { + method: "DELETE" + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetStatus(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( + function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + } + ); + } + function apiActivateSchedulerEntry(rigId, entryId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ entry_id: entryId }) + }).then(function(r) { + if (!r.ok) throw new Error("HTTP " + r.status); + return r.json(); + }); + } + function apiGetBookmarks() { + const url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; + return fetch(url).then(function(r) { + return r.ok ? r.json() : []; + }); + } + function loadScheduler() { + const rig = currentRigId; + if (!rig) return; + Promise.all([apiGetScheduler(rig), apiGetBookmarks()]).then(function([config, bms]) { + currentConfig = config; + bookmarkList = Array.isArray(bms) ? bms : []; + populateTsBookmarkSelect(); + renderScheduler(); + clearSchedulerDirty(); renderSchedulerInterleaveStatus(); - if (!currentRigId) return; - loadScheduler(); - pollStatus(); - } - function apiGetScheduler(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId)).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiPutScheduler(rigId, config) { - return fetch("/scheduler/" + encodeURIComponent(rigId), { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(config) - }).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiDeleteScheduler(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId), { - method: "DELETE" - }).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiGetStatus(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId) + "/status").then( - function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - } - ); - } - function apiActivateSchedulerEntry(rigId, entryId) { - return fetch("/scheduler/" + encodeURIComponent(rigId) + "/activate", { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ entry_id: entryId }) - }).then(function(r) { - if (!r.ok) throw new Error("HTTP " + r.status); - return r.json(); - }); - } - function apiGetBookmarks() { - const url = currentRigId ? "/bookmarks?scope=" + encodeURIComponent(currentRigId) : "/bookmarks"; - return fetch(url).then(function(r) { - return r.ok ? r.json() : []; - }); - } - function loadScheduler() { - const rig = currentRigId; - if (!rig) return; - Promise.all([apiGetScheduler(rig), apiGetBookmarks()]).then(function([config, bms]) { - currentConfig = config; - bookmarkList = Array.isArray(bms) ? bms : []; - populateTsBookmarkSelect(); - renderScheduler(); - clearSchedulerDirty(); - renderSchedulerInterleaveStatus(); - }).catch(function(error) { - console.error("scheduler load failed", error); - renderSchedulerInterleaveStatus(); - }); - } - function startStatusPolling() { - if (statusInterval) clearInterval(statusInterval); - statusInterval = setInterval(pollStatus, 15e3); - pollStatus(); - } - function startInterleaveTicker() { - if (interleaveTicker) clearInterval(interleaveTicker); - interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1e3); + }).catch(function(error) { + console.error("scheduler load failed", error); renderSchedulerInterleaveStatus(); + }); + } + function startStatusPolling() { + if (statusInterval) clearInterval(statusInterval); + statusInterval = setInterval(pollStatus, 15e3); + pollStatus(); + } + function startInterleaveTicker() { + if (interleaveTicker) clearInterval(interleaveTicker); + interleaveTicker = setInterval(renderSchedulerInterleaveStatus, 1e3); + renderSchedulerInterleaveStatus(); + } + function schedulerUtcSeconds() { + return Math.floor(Date.now() / 1e3); + } + function schedulerUtcMinuteInfo() { + const secs = schedulerUtcSeconds(); + const secsIntoDay = (secs % 86400 + 86400) % 86400; + return { + minuteOfDay: Math.floor(secsIntoDay / 60), + secondOfMinute: secsIntoDay % 60 + }; + } + function schedulerEntryIsActive(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return false; + if (start === end) return true; + if (start < end) return nowMin >= start && nowMin < end; + return nowMin >= start || nowMin < end; + } + function schedulerEntryCurrentWindowStart(entry, nowMin) { + const start = Number(entry && entry.start_min); + const end = Number(entry && entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; + if (start === end) return 0; + if (start < end) return start; + return nowMin >= start ? start : start - 1440; + } + function schedulerEntryDisplayName(entry) { + if (!entry) return "Scheduler entry"; + if (entry.label) return String(entry.label); + const bookmarkName = bmName(entry.bookmark_id); + return bookmarkName || "Scheduler entry"; + } + function schedulerInterleaveState(config) { + if (!config || config.mode !== "time_span") { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; } - function schedulerUtcSeconds() { - return Math.floor(Date.now() / 1e3); + const entries = Array.isArray(config.entries) ? config.entries : []; + const minuteInfo = schedulerUtcMinuteInfo(); + const nowMin = minuteInfo.minuteOfDay; + const active = entries.filter(function(entry) { + return schedulerEntryIsActive(entry, nowMin); + }); + if (active.length === 0) { + return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; } - function schedulerUtcMinuteInfo() { - const secs = schedulerUtcSeconds(); - const secsIntoDay = (secs % 86400 + 86400) % 86400; - return { - minuteOfDay: Math.floor(secsIntoDay / 60), - secondOfMinute: secsIntoDay % 60 - }; + if (active.length === 1) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; } - function schedulerEntryIsActive(entry, nowMin) { - const start = Number(entry && entry.start_min); - const end = Number(entry && entry.end_min); - if (!Number.isFinite(start) || !Number.isFinite(end)) return false; - if (start === end) return true; - if (start < end) return nowMin >= start && nowMin < end; - return nowMin >= start || nowMin < end; + const exclIdx = active.findIndex(function(e) { + return e.exclusive; + }); + if (exclIdx >= 0) { + const exclusive = active[exclIdx]; + return { activeEntries: exclusive ? [exclusive] : [], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; } - function schedulerEntryCurrentWindowStart(entry, nowMin) { - const start = Number(entry && entry.start_min); - const end = Number(entry && entry.end_min); - if (!Number.isFinite(start) || !Number.isFinite(end)) return Number.NEGATIVE_INFINITY; - if (start === end) return 0; - if (start < end) return start; - return nowMin >= start ? start : start - 1440; + const defaultInterleave = Number(config.interleave_min); + const durations = active.map(function(entry) { + const own = Number(entry && entry.interleave_min); + if (Number.isFinite(own) && own > 0) return Math.floor(own); + if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); + return 0; + }); + const cycleMin = durations.reduce(function(sum, value) { + return sum + value; + }, 0); + if (!(cycleMin > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; } - function schedulerEntryDisplayName(entry) { - if (!entry) return "Scheduler entry"; - if (entry.label) return String(entry.label); - const bookmarkName = bmName(entry.bookmark_id); - return bookmarkName || "Scheduler entry"; + const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id ? String(currentSchedulerStatus.last_entry_id) : ""; + const statusIndex = statusEntryId ? active.findIndex(function(entry) { + return String(entry && entry.id || "") === statusEntryId; + }) : -1; + const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc)) ? Number(currentSchedulerStatus.last_applied_utc) : null; + if (statusIndex >= 0 && statusAppliedUtc != null) { + const manualDurationMin = durations[statusIndex] ?? 0; + const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); + const remainingSec2 = manualDurationMin > 0 ? Math.max(1, manualDurationMin * 60 - elapsedSec) : 0; + if (remainingSec2 > 0) { + return { + activeEntries: active, + currentIndex: statusIndex, + remainingSec: remainingSec2, + cycleMin + }; + } } - function schedulerInterleaveState(config) { - if (!config || config.mode !== "time_span") { - return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; - } - const entries = Array.isArray(config.entries) ? config.entries : []; - const minuteInfo = schedulerUtcMinuteInfo(); - const nowMin = minuteInfo.minuteOfDay; - const active = entries.filter(function(entry) { - return schedulerEntryIsActive(entry, nowMin); - }); - if (active.length === 0) { - return { activeEntries: [], currentIndex: -1, remainingSec: 0, cycleMin: 0 }; - } - if (active.length === 1) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const exclIdx = active.findIndex(function(e) { - return e.exclusive; - }); - if (exclIdx >= 0) { - const exclusive = active[exclIdx]; - return { activeEntries: exclusive ? [exclusive] : [], currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const defaultInterleave = Number(config.interleave_min); - const durations = active.map(function(entry) { - const own = Number(entry && entry.interleave_min); - if (Number.isFinite(own) && own > 0) return Math.floor(own); - if (Number.isFinite(defaultInterleave) && defaultInterleave > 0) return Math.floor(defaultInterleave); - return 0; - }); - const cycleMin = durations.reduce(function(sum, value) { - return sum + value; - }, 0); - if (!(cycleMin > 0)) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const statusEntryId = currentSchedulerStatus && currentSchedulerStatus.last_entry_id ? String(currentSchedulerStatus.last_entry_id) : ""; - const statusIndex = statusEntryId ? active.findIndex(function(entry) { - return String(entry && entry.id || "") === statusEntryId; - }) : -1; - const statusAppliedUtc = currentSchedulerStatus && Number.isFinite(Number(currentSchedulerStatus.last_applied_utc)) ? Number(currentSchedulerStatus.last_applied_utc) : null; - if (statusIndex >= 0 && statusAppliedUtc != null) { - const manualDurationMin = durations[statusIndex] ?? 0; - const elapsedSec = Math.max(0, schedulerUtcSeconds() - statusAppliedUtc); - const remainingSec2 = manualDurationMin > 0 ? Math.max(1, manualDurationMin * 60 - elapsedSec) : 0; - if (remainingSec2 > 0) { - return { - activeEntries: active, - currentIndex: statusIndex, - remainingSec: remainingSec2, - cycleMin - }; - } - } - const overlapStart = active.reduce(function(maxStart, entry) { - return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); - }, Number.NEGATIVE_INFINITY); - if (!Number.isFinite(overlapStart)) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; - } - const nowMinPrecise = minuteInfo.minuteOfDay + minuteInfo.secondOfMinute / 60; - const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; - let cumulative = 0; - let slotStart = 0; - let currentIndex = 0; - let currentDuration = 0; - for (let i = 0; i < durations.length; i += 1) { - const duration = durations[i] ?? 0; - const nextCumulative = cumulative + duration; - if (posMin < nextCumulative) { - slotStart = cumulative; - cumulative = nextCumulative; - currentIndex = i; - currentDuration = duration; - break; - } + const overlapStart = active.reduce(function(maxStart, entry) { + return Math.max(maxStart, schedulerEntryCurrentWindowStart(entry, nowMin)); + }, Number.NEGATIVE_INFINITY); + if (!Number.isFinite(overlapStart)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const nowMinPrecise = minuteInfo.minuteOfDay + minuteInfo.secondOfMinute / 60; + const posMin = ((nowMinPrecise - overlapStart) % cycleMin + cycleMin) % cycleMin; + let cumulative = 0; + let slotStart = 0; + let currentIndex = 0; + let currentDuration = 0; + for (let i = 0; i < durations.length; i += 1) { + const duration = durations[i] ?? 0; + const nextCumulative = cumulative + duration; + if (posMin < nextCumulative) { + slotStart = cumulative; cumulative = nextCumulative; + currentIndex = i; + currentDuration = duration; + break; } - if (!(currentDuration > 0)) { - return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + cumulative = nextCumulative; + } + if (!(currentDuration > 0)) { + return { activeEntries: active, currentIndex: 0, remainingSec: 0, cycleMin: 0 }; + } + const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); + const remainingSec = Math.max(1, currentDuration * 60 - elapsedSlotSec); + return { + activeEntries: active, + currentIndex, + remainingSec, + cycleMin + }; + } + function renderSchedulerInterleaveStatus() { + const wrap = schedulerEl("scheduler-cycle-status"); + if (!wrap) return; + const state = schedulerInterleaveState(currentConfig); + const isActive = state.activeEntries.length > 1 && state.cycleMin > 0; + wrap.style.display = isActive ? "" : "none"; + if (isActive) { + const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); + const totalSlotSec = state.cycleMin > 0 ? state.cycleMin * 60 / state.activeEntries.length : 0; + const elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, (totalSlotSec - state.remainingSec) / totalSlotSec * 100)) : 0; + const ringFill = schedulerEl("interleave-ring-fill"); + if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); + const nameEl = schedulerEl("interleave-active-name"); + if (nameEl) nameEl.textContent = activeName; + const countdownEl = schedulerEl("interleave-countdown"); + if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle"; + } + renderTimelineNeedle(); + renderSchedulerStepControls(); + } + function renderSchedulerStepControls() { + const prevBtn = schedulerEl("scheduler-prev-btn"); + const nextBtn = schedulerEl("scheduler-next-btn"); + if (!prevBtn || !nextBtn) return; + const state = schedulerInterleaveState(currentConfig); + const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; + prevBtn.disabled = !enabled; + nextBtn.disabled = !enabled; + const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; + prevBtn.title = hint; + nextBtn.title = hint; + } + function pollStatus() { + const rig = currentRigId; + if (!rig) return; + apiGetStatus(rig).then(function(st) { + currentSchedulerStatus = st || null; + renderStatus(st); + renderSchedulerInterleaveStatus(); + renderActivityLog(); + renderSatPassStatus(); + }).catch(function() { + }); + } + function renderStatus(st) { + const el = schedulerEl("scheduler-status-card"); + if (!el) return; + if (!st || !st.active && !st.last_bookmark_id) { + el.textContent = "No activity yet."; + return; + } + const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : ""; + const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries.find(function(item) { + return String(item && item.id || "") === statusEntryId; + }) : null; + const name = entry ? schedulerEntryDisplayName(entry) : st.last_bookmark_name || st.last_bookmark_id || "—"; + let ts = ""; + if (st.last_applied_utc) { + const d = new Date(st.last_applied_utc * 1e3); + ts = " at " + d.toUTCString(); + } + const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; + let details = ""; + if (st.freq_hz) { + details += formatFreq(st.freq_hz); + if (st.mode) details += " · " + st.mode; + if (st.active_decoders && st.active_decoders.length > 0) { + details += " · " + st.active_decoders.join(", ") + " active"; } - const elapsedSlotSec = Math.max(0, Math.floor((posMin - slotStart) * 60)); - const remainingSec = Math.max(1, currentDuration * 60 - elapsedSlotSec); - return { - activeEntries: active, - currentIndex, - remainingSec, - cycleMin - }; } - function renderSchedulerInterleaveStatus() { - const wrap = schedulerEl("scheduler-cycle-status"); - if (!wrap) return; - const state = schedulerInterleaveState(currentConfig); - const isActive = state.activeEntries.length > 1 && state.cycleMin > 0; - wrap.style.display = isActive ? "" : "none"; - if (isActive) { - const activeName = schedulerEntryDisplayName(state.activeEntries[state.currentIndex]); - const totalSlotSec = state.cycleMin > 0 ? state.cycleMin * 60 / state.activeEntries.length : 0; - const elapsedPct = totalSlotSec > 0 ? Math.min(100, Math.max(0, (totalSlotSec - state.remainingSec) / totalSlotSec * 100)) : 0; - const ringFill = schedulerEl("interleave-ring-fill"); - if (ringFill) ringFill.setAttribute("stroke-dashoffset", String(100 - elapsedPct)); - const nameEl = schedulerEl("interleave-active-name"); - if (nameEl) nameEl.textContent = activeName; - const countdownEl = schedulerEl("interleave-countdown"); - if (countdownEl) countdownEl.textContent = "next in " + state.remainingSec + "s · " + state.cycleMin + "m cycle"; - } - renderTimelineNeedle(); - renderSchedulerStepControls(); + if (details) { + el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts + '
' + escHtml(details) + ""; + } else { + el.textContent = "Last applied: " + name + satLabel + ts; } - function renderSchedulerStepControls() { - const prevBtn = schedulerEl("scheduler-prev-btn"); - const nextBtn = schedulerEl("scheduler-next-btn"); - if (!prevBtn || !nextBtn) return; - const state = schedulerInterleaveState(currentConfig); - const enabled = schedulerRole === "control" && !!currentRigId && !schedulerStepPending && state.activeEntries.length > 1; - prevBtn.disabled = !enabled; - nextBtn.disabled = !enabled; - const hint = enabled ? "Select a different active scheduler entry" : "Available only when multiple scheduler entries are active"; - prevBtn.title = hint; - nextBtn.title = hint; - } - function pollStatus() { - const rig = currentRigId; - if (!rig) return; - apiGetStatus(rig).then(function(st) { - currentSchedulerStatus = st || null; - renderStatus(st); - renderSchedulerInterleaveStatus(); - renderActivityLog(); - renderSatPassStatus(); - }).catch(function() { - }); - } - function renderStatus(st) { - const el = schedulerEl("scheduler-status-card"); - if (!el) return; - if (!st || !st.active && !st.last_bookmark_id) { - el.textContent = "No activity yet."; + } + function apiGetSchedulerLog(rigId) { + return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function(r) { + return r.ok ? r.json() : []; + }); + } + function renderActivityLog() { + const wrap = schedulerEl("scheduler-activity-log-wrap"); + const container = schedulerEl("scheduler-activity-log"); + if (!wrap || !container || !currentRigId) return; + apiGetSchedulerLog(currentRigId).then(function(entries) { + if (!entries || entries.length === 0) { + wrap.style.display = "none"; return; } - const statusEntryId = st.last_entry_id ? String(st.last_entry_id) : ""; - const entry = statusEntryId && currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries.find(function(item) { - return String(item && item.id || "") === statusEntryId; - }) : null; - const name = entry ? schedulerEntryDisplayName(entry) : st.last_bookmark_name || st.last_bookmark_id || "—"; - let ts = ""; - if (st.last_applied_utc) { - const d = new Date(st.last_applied_utc * 1e3); - ts = " at " + d.toUTCString(); - } - const satLabel = st.active_satellite ? " [SAT: " + st.active_satellite + "]" : ""; - let details = ""; - if (st.freq_hz) { - details += formatFreq(st.freq_hz); - if (st.mode) details += " · " + st.mode; - if (st.active_decoders && st.active_decoders.length > 0) { - details += " · " + st.active_decoders.join(", ") + " active"; - } - } - if (details) { - el.innerHTML = "Last applied: " + escHtml(name) + satLabel + ts + '
' + escHtml(details) + ""; - } else { - el.textContent = "Last applied: " + name + satLabel + ts; - } - } - function apiGetSchedulerLog(rigId) { - return fetch("/scheduler/" + encodeURIComponent(rigId) + "/log").then(function(r) { - return r.ok ? r.json() : []; - }); - } - function renderActivityLog() { - const wrap = schedulerEl("scheduler-activity-log-wrap"); - const container = schedulerEl("scheduler-activity-log"); - if (!wrap || !container || !currentRigId) return; - apiGetSchedulerLog(currentRigId).then(function(entries) { - if (!entries || entries.length === 0) { - wrap.style.display = "none"; - return; - } - wrap.style.display = ""; - const html = entries.slice().reverse().map(function(e) { - const d = new Date(e.utc * 1e3); - const ts = d.toUTCString(); - const action = e.action || "unknown"; - const label = e.entry_label || ""; - const bm = e.bookmark_name || ""; - return '
' + escHtml(ts) + ' ' + escHtml(action) + " " + (bm ? '' + escHtml(bm) + "" : "") + (label ? ' (' + escHtml(label) + ")" : "") + "
"; - }).join(""); - container.innerHTML = html; - }).catch(function() { - }); - } - function renderScheduler() { - const panel = schedulerEl("scheduler-panel"); - if (!panel) return; - const mode = currentConfig && currentConfig.mode || "disabled"; - const isControl = schedulerRole === "control"; - setSelected("scheduler-mode-select", mode); - const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; - const controlRow = document.querySelector(".scheduler-control-row"); - if (controlRow) controlRow.style.display = mode !== "disabled" || satEnabled ? "" : "none"; - const glSection = schedulerEl("scheduler-grayline-section"); - const tsSection = schedulerEl("scheduler-timespan-section"); - if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; - if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; - renderSatelliteSection(); - if (mode === "grayline" && currentConfig && currentConfig.grayline) { - const gl = currentConfig.grayline; - const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; - const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; - setInputValue("scheduler-gl-lat", lat != null ? lat : ""); - setInputValue("scheduler-gl-lon", lon != null ? lon : ""); - const gridEl = schedulerEl("scheduler-gl-grid"); - if (gridEl) { - gridEl.value = latLonToGrid(lat, lon); - } - setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); - renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id); - renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id); - renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); - renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); - } else if (mode === "grayline") { - const lat = schedulerWindow.serverLat ?? ""; - const lon = schedulerWindow.serverLon ?? ""; - setInputValue("scheduler-gl-lat", lat != null ? lat : ""); - setInputValue("scheduler-gl-lon", lon != null ? lon : ""); - const gridEl2 = schedulerEl("scheduler-gl-grid"); - if (gridEl2 && lat !== "" && lon !== "") { - gridEl2.value = latLonToGrid(lat, lon); - } - setInputValue("scheduler-gl-window", 20); - renderBookmarkSelect("scheduler-gl-dawn", null); - renderBookmarkSelect("scheduler-gl-day", null); - renderBookmarkSelect("scheduler-gl-dusk", null); - renderBookmarkSelect("scheduler-gl-night", null); - } else { - renderBookmarkSelect("scheduler-gl-dawn", null); - renderBookmarkSelect("scheduler-gl-day", null); - renderBookmarkSelect("scheduler-gl-dusk", null); - renderBookmarkSelect("scheduler-gl-night", null); - } - const ilEl = schedulerEl("scheduler-ts-interleave"); - if (ilEl) { - const il = currentConfig && currentConfig.interleave_min; - ilEl.value = il ? String(il) : ""; - } - renderTimespanEntries(); - const formEls = panel.querySelectorAll("input, select, button.sch-write"); - formEls.forEach(function(el) { - el.disabled = !isControl; - }); - const saveBtn = schedulerEl("scheduler-save-btn"); - if (saveBtn) { - saveBtn.style.display = isControl ? "" : "none"; - } - const resetBtn = schedulerEl("scheduler-reset-btn"); - if (resetBtn) { - resetBtn.style.display = isControl ? "" : "none"; - } - } - function setSelected(id, value) { - const el = schedulerEl(id); - if (el) el.value = value; - } - function setInputValue(id, value) { - const el = schedulerEl(id); - if (el) el.value = String(value); - } - function renderBookmarkSelect(id, selectedId) { - const sel = schedulerEl(id); - if (!sel) return; - sel.innerHTML = ''; - bookmarkList.forEach(function(bm) { - const opt = document.createElement("option"); - opt.value = bm.id; - opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; - if (bm.id === selectedId) opt.selected = true; - sel.appendChild(opt); - }); - } - function formatFreq(hz) { - if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; - if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; - return hz + " Hz"; - } - function schOpenEntryForm(entry, idx) { - schEntryEditIdx = idx != null ? idx : null; - const titleEl = schedulerEl("sch-entry-form-title"); - if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; - const startEl = schedulerEl("scheduler-ts-start"); - const endEl = schedulerEl("scheduler-ts-end"); - const bmEl = schedulerEl("scheduler-ts-bookmark"); - const labelEl = schedulerEl("scheduler-ts-label"); - const ilEl = schedulerEl("scheduler-ts-entry-interleave"); - const centerHzEl = schedulerEl("scheduler-ts-center-hz"); - if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; - if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; - if (bmEl) bmEl.value = entry ? entry.bookmark_id || "" : ""; - if (labelEl) labelEl.value = entry ? entry.label || "" : ""; - if (ilEl) ilEl.value = entry?.interleave_min ? String(entry.interleave_min) : ""; - if (centerHzEl) centerHzEl.value = entry?.center_hz ? String(entry.center_hz) : ""; - const recordEl = schedulerEl("scheduler-ts-entry-record"); - if (recordEl) recordEl.checked = !!(entry && entry.record); - pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; - renderExtraBmList(); - const wrap = schedulerEl("sch-entry-form-wrap"); - if (wrap) { - wrap.style.display = "block"; - if (startEl) startEl.focus(); - } - } - function schCloseEntryForm() { - const wrap = schedulerEl("sch-entry-form-wrap"); - if (wrap) wrap.style.display = "none"; - schEntryEditIdx = null; - pendingExtraBmIds = []; - } - function schEntryFormSubmit(e) { - e.preventDefault(); - const startEl = schedulerEl("scheduler-ts-start"); - const endEl = schedulerEl("scheduler-ts-end"); - const bmEl = schedulerEl("scheduler-ts-bookmark"); - const labelEl = schedulerEl("scheduler-ts-label"); - const ilEl = schedulerEl("scheduler-ts-entry-interleave"); - const centerHzEl = schedulerEl("scheduler-ts-center-hz"); - if (!startEl || !endEl || !bmEl) return; - const bmId = bmEl.value; - if (!bmId) { - schedulerWindow.trxUi.notify?.("Select a primary bookmark before saving.", { kind: "error" }); - return; - } - const startMin = hhmmToMin(startEl.value); - const endMin = hhmmToMin(endEl.value); - const label = labelEl ? labelEl.value.trim() : ""; - const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; - const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; - const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN; - const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; - const extraBmIds = pendingExtraBmIds.slice(); - currentConfig ??= { remote: currentRigId, mode: "time_span", grayline: null, entries: [] }; - const config = currentConfig; - const recordCb = schedulerEl("scheduler-ts-entry-record"); - const entryRecord = recordCb ? recordCb.checked : false; - const entryData = { - id: "ts_" + Date.now().toString(36), - start_min: startMin, - end_min: endMin, - bookmark_id: bmId, - label: label || null, - interleave_min: entryInterleave, - center_hz: centerHz, - bookmark_ids: extraBmIds, - record: entryRecord, - exclusive: false - }; - if (schEntryEditIdx !== null) { - const existing = config.entries[schEntryEditIdx]; - if (existing?.id) entryData.id = existing.id; - config.entries[schEntryEditIdx] = entryData; - } else { - config.entries.push(entryData); - } - schCloseEntryForm(); - renderTimespanEntries(); - markSchedulerDirty(); - } - const TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; - function renderTimeline() { - const container = schedulerEl("scheduler-ts-timeline"); - if (!container) return; - const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; - if (entries.length === 0) { - container.innerHTML = ""; - return; - } - const W = 1e3; - const H = 80; - const BAR_Y = 6; - const BAR_H = 30; - const TICK_Y = BAR_Y + BAR_H + 2; - let svg = ''; - svg += ''; - entries.forEach(function(entry, idx) { - const start = Number(entry.start_min); - const end = Number(entry.end_min); - if (!Number.isFinite(start) || !Number.isFinite(end)) return; - const color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; - if (start === end) { - svg += ''; - } else if (start < end) { - const x = start / 1440 * W; - const w = (end - start) / 1440 * W; - svg += ''; - } else { - const x1 = start / 1440 * W; - const w1 = W - x1; - svg += ''; - const w2 = end / 1440 * W; - svg += ''; - } - }); - const interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; - if (interleaveMin > 0 && entries.length > 1) { - for (let m = 0; m < 1440; m += interleaveMin) { - const overlapping = []; - entries.forEach(function(entry, idx) { - if (schedulerEntryIsActive(entry, m)) { - overlapping.push(idx); - } - }); - if (overlapping.length > 1) { - const stripeX = m / 1440 * W; - const stripeW = Math.max(1, interleaveMin / 1440 * W); - const cyclePos = m % (interleaveMin * overlapping.length); - const ownerSlot = Math.floor(cyclePos / interleaveMin); - const ownerIdx = overlapping[ownerSlot % overlapping.length] ?? 0; - const stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; - svg += ''; - } - } - } - for (let h = 0; h <= 24; h += 3) { - const tx = h / 24 * W; - svg += ''; - if (h < 24) { - svg += '' + String(h).padStart(2, "0") + ""; - } - } - const LOCAL_TICK_Y = TICK_Y + 18; - for (let h = 0; h < 24; h += 3) { - const localMin = h * 60; - const utcOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); - const utcMin = (localMin + utcOffset + 1440) % 1440; - const tx = utcMin / 1440 * W; - svg += '' + String(h).padStart(2, "0") + "L"; - } - svg += '' + timelineNeedleSvg() + ""; - svg += ""; - container.innerHTML = svg; - container.querySelectorAll(".sch-timeline-seg").forEach(function(seg) { - seg.addEventListener("click", function() { - const i = parseInt(seg.getAttribute("data-idx") ?? "", 10); - const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; - if (entry) schOpenEntryForm(entry, i); - }); - }); - const svgEl = container.querySelector("svg"); - if (svgEl) { - const timelineSvg = svgEl; - svgEl.addEventListener("click", function(e) { - if (e.target instanceof Element && e.target.classList.contains("sch-timeline-seg")) return; - const rect = timelineSvg.getBoundingClientRect(); - const xPct = (e.clientX - rect.left) / rect.width; - const clickMin = Math.floor(xPct * 1440); - const startHour = Math.floor(clickMin / 60); - const startMin = startHour * 60; - const endMin = (startHour + 1) % 24 * 60; - schOpenEntryForm(null); - const startEl = schedulerEl("scheduler-ts-start"); - const endEl = schedulerEl("scheduler-ts-end"); - if (startEl) startEl.value = minToHHMM(startMin); - if (endEl) endEl.value = minToHHMM(endMin); - }); - svgEl.style.cursor = "crosshair"; - } - } - function timelineNeedleSvg() { - const info = schedulerUtcMinuteInfo(); - const nowMin = info.minuteOfDay + info.secondOfMinute / 60; - const x = nowMin / 1440 * 1e3; - return ''; - } - function renderTimelineNeedle() { - const g = schedulerEl("sch-timeline-needle-g"); - if (g) g.innerHTML = timelineNeedleSvg(); - } - function schInlineEdit(tr, entry, idx) { - const bmOptions = bookmarkList.map(function(bm) { - const sel = bm.id === entry.bookmark_id ? " selected" : ""; - return '"; + wrap.style.display = ""; + const html = entries.slice().reverse().map(function(e) { + const d = new Date(e.utc * 1e3); + const ts = d.toUTCString(); + const action = e.action || "unknown"; + const label = e.entry_label || ""; + const bm = e.bookmark_name || ""; + return '
' + escHtml(ts) + ' ' + escHtml(action) + " " + (bm ? '' + escHtml(bm) + "" : "") + (label ? ' (' + escHtml(label) + ")" : "") + "
"; }).join(""); - const extraBmOptions = '' + bookmarkList.map(function(bm) { - return '"; - }).join(""); - const inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; - tr.innerHTML = '⠇' + (entry.center_hz ? formatFreq(entry.center_hz) : "—") + '
'; - tr.classList.add("sch-inline-editing"); - const chipsContainer = tr.querySelector(".sch-inline-extra-chips"); - const extraPick = tr.querySelector(".sch-inline-extra-pick"); - const extraAddBtn = tr.querySelector(".sch-inline-extra-add"); - if (!chipsContainer || !extraPick || !extraAddBtn) return; - const chips = chipsContainer; - const picker = extraPick; - function renderInlineExtraChips() { - chips.innerHTML = ""; - inlineExtraIds.forEach(function(id, i) { - const chip = document.createElement("span"); - chip.className = "sch-extra-bm-chip"; - const rmBtn = document.createElement("span"); - rmBtn.className = "sch-extra-bm-chip-rm"; - rmBtn.textContent = "×"; - rmBtn.title = "Remove"; - rmBtn.addEventListener("click", function() { - inlineExtraIds.splice(i, 1); - renderInlineExtraChips(); - }); - chip.appendChild(rmBtn); - chip.appendChild(document.createTextNode(" " + bmName(id))); - chips.appendChild(chip); - }); - Array.from(picker.options).forEach(function(opt) { - if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); - }); - } - renderInlineExtraChips(); - extraAddBtn.addEventListener("click", function() { - if (!picker.value) return; - if (!inlineExtraIds.includes(picker.value)) { - inlineExtraIds.push(picker.value); - renderInlineExtraChips(); - } - picker.value = ""; - }); - const exclEl = tr.querySelector('[data-field="exclusive"]'); - const ilInput = tr.querySelector('[data-field="interleave"]'); - if (exclEl && ilInput) { - const exclusiveInput = exclEl; - const interleaveInput = ilInput; - exclEl.addEventListener("change", function() { - interleaveInput.disabled = exclusiveInput.checked; - if (exclusiveInput.checked) interleaveInput.value = ""; - }); - } - tr.querySelector(".sch-inline-save")?.addEventListener("click", function() { - const startEl = tr.querySelector('[data-field="start"]'); - const endEl = tr.querySelector('[data-field="end"]'); - const bmEl = tr.querySelector('[data-field="bookmark"]'); - const labelEl = tr.querySelector('[data-field="label"]'); - const ilEl = tr.querySelector('[data-field="interleave"]'); - const recEl = tr.querySelector('[data-field="record"]'); - const exEl = tr.querySelector('[data-field="exclusive"]'); - if (!startEl || !endEl || !bmEl || !labelEl || !ilEl || !recEl) return; - if (bmEl && !bmEl.value) { - schedulerWindow.trxUi.notify?.("Select a bookmark before saving.", { kind: "error" }); - bmEl.focus(); - return; - } - entry.start_min = hhmmToMin(startEl.value); - entry.end_min = hhmmToMin(endEl.value); - entry.bookmark_id = bmEl.value; - entry.label = labelEl.value.trim() || null; - entry.exclusive = exEl ? exEl.checked : false; - const ilVal = parseInt(ilEl.value, 10); - entry.interleave_min = entry.exclusive ? null : !isNaN(ilVal) && ilVal > 0 ? ilVal : null; - entry.bookmark_ids = inlineExtraIds.slice(); - entry.record = recEl.checked; - if (currentConfig) currentConfig.entries[idx] = entry; - renderTimespanEntries(); - markSchedulerDirty(); - }); - tr.querySelector(".sch-inline-cancel")?.addEventListener("click", function() { - renderTimespanEntries(); - }); - } - function renderTimespanEntries() { - const tbody = schedulerEl("scheduler-ts-tbody"); - if (!tbody) return; - tbody.innerHTML = ""; - const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; - entries.forEach(function(entry, idx) { - const tr = document.createElement("tr"); - if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id && entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) { - tr.classList.add("sch-active"); - } - const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "—"; - const allDay = entry.start_min === entry.end_min; - const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—"; - const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; - const extraCell = extraIds.length ? extraIds.map(function(id) { - return escHtml(bmName(id)); - }).join(", ") : "—"; - tr.innerHTML = '⠇' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ")") + "" + (allDay ? "—" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ")") + "" + centerCell + "" + escHtml(bmName(entry.bookmark_id)) + "" + extraCell + "" + escHtml(entry.label || "") + "" + il + "" + (entry.record ? "Yes" : "") + ''; - tbody.appendChild(tr); - }); - tbody.querySelectorAll(".sch-edit-btn").forEach(function(btn) { - btn.addEventListener("click", function() { - const i = parseInt(btn.dataset.idx ?? "", 10); - const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; - const row = btn.closest("tr"); - if (entry && row) schInlineEdit(row, entry, i); - }); - }); - tbody.querySelectorAll(".sch-remove-btn").forEach(function(btn) { - btn.addEventListener("click", function() { - removeEntry(parseInt(btn.dataset.idx ?? "", 10)); - }); - }); - (function() { - const handles = tbody.querySelectorAll(".sch-drag-handle"); - let dragIdx = null; - handles.forEach(function(handle, idx) { - const row = handle.parentElement; - if (!row) return; - const dragRow = row; - handle.addEventListener("dragstart", function(event) { - const e = event; - dragIdx = idx; - dragRow.classList.add("sch-dragging"); - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = "move"; - e.dataTransfer.setData("text/plain", String(idx)); - } - }); - dragRow.addEventListener("dragover", function(event) { - const e = event; - e.preventDefault(); - if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; - dragRow.classList.add("sch-drag-over"); - }); - dragRow.addEventListener("dragleave", function() { - dragRow.classList.remove("sch-drag-over"); - }); - dragRow.addEventListener("drop", function(event) { - const e = event; - e.preventDefault(); - dragRow.classList.remove("sch-drag-over"); - if (dragIdx === null || dragIdx === idx) return; - if (!currentConfig) return; - const entries2 = currentConfig.entries; - const moved = entries2.splice(dragIdx, 1)[0]; - if (moved) entries2.splice(idx, 0, moved); - renderTimespanEntries(); - markSchedulerDirty(); - }); - handle.addEventListener("dragend", function() { - dragRow.classList.remove("sch-dragging"); - dragIdx = null; - }); - }); - })(); - renderTimeline(); - } - function bmName(id) { - const bm = bookmarkList.find(function(b) { - return b.id === id; - }); - return bm ? bm.name : String(id || ""); - } - function minToLocal(min) { - const now = /* @__PURE__ */ new Date(); - const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); - const utcMs = utcMidnight.getTime() + min * 6e4; - const local = new Date(utcMs); - return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); - } - function minToHHMM(min) { - const h = Math.floor(min / 60) % 24; - const m = min % 60; - return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); - } - function hhmmToMin(str) { - const parts = str.split(":"); - return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); - } - function gridToLatLon(grid) { - grid = String(grid).toUpperCase().trim(); - if (grid.length < 4) return null; - const lonField = grid.charCodeAt(0) - 65; - const latField = grid.charCodeAt(1) - 65; - const lonSquare = parseInt(grid.charAt(2), 10); - const latSquare = parseInt(grid.charAt(3), 10); - if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; - let lon = lonField * 20 + lonSquare * 2 - 180; - let lat = latField * 10 + latSquare * 1 - 90; - if (grid.length >= 6) { - const lonSub = grid.charCodeAt(4) - 65; - const latSub = grid.charCodeAt(5) - 65; - if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { - lon += lonSub * (2 / 24) + 1 / 24; - lat += latSub * (1 / 24) + 0.5 / 24; - } - } else { - lon += 1; - lat += 0.5; - } - return { lat, lon }; - } - function latLonToGrid(lat, lon) { - lon += 180; - lat += 90; - if (isNaN(lon) || isNaN(lat)) return ""; - const lonField = String.fromCharCode(65 + Math.floor(lon / 20)); - const latField = String.fromCharCode(65 + Math.floor(lat / 10)); - const lonSquare = Math.floor(lon % 20 / 2); - const latSquare = Math.floor(lat % 10); - const lonSub = String.fromCharCode(97 + Math.floor(lon % 2 / 2 * 24)); - const latSub = String.fromCharCode(97 + Math.floor(lat % 1 * 24)); - return lonField + latField + lonSquare + latSquare + lonSub + latSub; - } - function escHtml(s) { - return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - function schedulerSelectRelativeEntry(delta) { - const state = schedulerInterleaveState(currentConfig); - const rigId = currentRigId; - if (!rigId || schedulerStepPending || state.activeEntries.length <= 1) return; - const count = state.activeEntries.length; - const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; - const targetIndex = (currentIndex + delta + count) % count; - const target = state.activeEntries[targetIndex]; - if (!target || !target.id) return; - const targetId = target.id; - schedulerStepPending = true; - renderSchedulerStepControls(); - Promise.resolve(schedulerWindow.vchanTakeSchedulerControl?.() ?? null).then(function() { - return apiActivateSchedulerEntry(rigId, targetId); - }).then(function(status) { - currentSchedulerStatus = status || null; - return Promise.resolve( - schedulerWindow.vchanToggleSchedulerRelease?.() ?? null - ).then(function() { - renderStatus(status); - renderSchedulerInterleaveStatus(); - showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); - pollStatus(); - }); - }).catch(function(error) { - console.error("scheduler entry selection failed", error); - showSchedulerToast("Scheduler entry selection failed: " + (error instanceof Error ? error.message : String(error)), true); - }).finally(function() { - schedulerStepPending = false; - renderSchedulerStepControls(); - }); - } - function removeEntry(idx) { - if (!currentConfig || !currentConfig.entries) return; - currentConfig.entries.splice(idx, 1); - renderTimespanEntries(); - markSchedulerDirty(); - } - function bookmarkExists(id) { - if (!id) return true; - return bookmarkList.some(function(bm) { - return bm.id === id; - }); - } - function saveScheduler() { - const rig = currentRigId; - if (!rig) return; - const modeEl = schedulerEl("scheduler-mode-select"); - const rawMode = modeEl ? modeEl.value : "disabled"; - const mode = rawMode === "grayline" || rawMode === "time_span" ? rawMode : "disabled"; - const config = { - remote: rig, - mode, - grayline: null, - entries: [] - }; - if (mode === "grayline") { - const lat = parseFloat(schedulerEl("scheduler-gl-lat").value); - const lon = parseFloat(schedulerEl("scheduler-gl-lon").value); - const win = parseInt(schedulerEl("scheduler-gl-window").value, 10); - config.grayline = { - lat: isNaN(lat) ? 0 : lat, - lon: isNaN(lon) ? 0 : lon, - transition_window_min: isNaN(win) ? 20 : win, - dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null, - day_bookmark_id: selectVal("scheduler-gl-day") || null, - dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null, - night_bookmark_id: selectVal("scheduler-gl-night") || null - }; - } else if (mode === "time_span") { - config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; - const ilVal = parseInt(schedulerEl("scheduler-ts-interleave").value, 10); - config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; - } - config.satellites = collectSatelliteConfig(); - const missingBmErrors = []; - if (mode === "grayline" && config.grayline) { - const gl = config.grayline; - const glFields = [ - ["dawn_bookmark_id", "Grayline dawn"], - ["day_bookmark_id", "Grayline day"], - ["dusk_bookmark_id", "Grayline dusk"], - ["night_bookmark_id", "Grayline night"] - ]; - glFields.forEach(function(pair) { - const bookmarkId = gl[pair[0]]; - if (typeof bookmarkId === "string" && !bookmarkExists(bookmarkId)) missingBmErrors.push(pair[1] + " (bookmark " + bookmarkId + ")"); - }); - } - if (mode === "time_span" && Array.isArray(config.entries)) { - config.entries.forEach(function(entry, idx) { - const label = entry.label || "Entry #" + (idx + 1); - if (!bookmarkExists(entry.bookmark_id)) { - missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); - } - const extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; - extras.forEach(function(id) { - if (!bookmarkExists(id)) { - missingBmErrors.push(label + " extra channel (" + id + ")"); - } - }); - }); - } - if (config.satellites && Array.isArray(config.satellites.entries)) { - config.satellites.entries.forEach(function(sat, idx) { - const satLabel = sat.satellite || "Satellite #" + (idx + 1); - if (!bookmarkExists(sat.bookmark_id)) { - missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); - } - }); - } - if (missingBmErrors.length > 0) { - showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true); - return; - } - const btn = schedulerEl("scheduler-save-btn"); - if (btn) btn.disabled = true; - apiPutScheduler(rig, config).then(function(saved) { - currentConfig = saved; - renderScheduler(); - clearSchedulerDirty(); - showSchedulerToast("Scheduler saved."); - }).catch(function(error) { - showSchedulerToast("Save failed: " + (error instanceof Error ? error.message : String(error)), true); - }).finally(function() { - if (btn) btn.disabled = false; - }); - } - function selectVal(id) { - const el = schedulerEl(id); - return el ? el.value : ""; - } - async function resetScheduler() { - const rig = currentRigId; - if (!rig) return; - if (!await schedulerWindow.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; - apiDeleteScheduler(rig).then(function() { - currentConfig = { - remote: rig, - mode: "disabled", - grayline: null, - entries: [] - }; - renderScheduler(); - clearSchedulerDirty(); - showSchedulerToast("Scheduler reset."); - }).catch(function(error) { - showSchedulerToast("Reset failed: " + (error instanceof Error ? error.message : String(error)), true); - }); - } - function markSchedulerDirty() { - if (schedulerDirty) return; - schedulerDirty = true; - const btn = schedulerEl("scheduler-save-btn"); - if (btn) btn.classList.add("sch-dirty"); - } - function clearSchedulerDirty() { - schedulerDirty = false; - const btn = schedulerEl("scheduler-save-btn"); - if (btn) btn.classList.remove("sch-dirty"); - } - function showSchedulerToast(msg, isError = false) { - const el = schedulerEl("scheduler-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 wireSchedulerEvents() { - const modeEl = schedulerEl("scheduler-mode-select"); - if (modeEl) { - modeEl.addEventListener("change", function() { - const mode = modeEl.value === "grayline" || modeEl.value === "time_span" ? modeEl.value : "disabled"; - currentConfig ??= { remote: currentRigId, mode, grayline: null, entries: [] }; - currentConfig.mode = mode; - renderScheduler(); - }); - } - const saveBtn = schedulerEl("scheduler-save-btn"); - if (saveBtn) saveBtn.addEventListener("click", saveScheduler); - const resetBtn = schedulerEl("scheduler-reset-btn"); - if (resetBtn) resetBtn.addEventListener("click", () => { - void resetScheduler(); - }); - const addBtn = schedulerEl("scheduler-ts-add-btn"); - if (addBtn) addBtn.addEventListener("click", function() { - schOpenEntryForm(null); - }); - const entryForm = schedulerEl("sch-entry-form"); - if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); - const cancelBtn = schedulerEl("sch-entry-form-cancel"); - if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); - const prevBtn = schedulerEl("scheduler-prev-btn"); - if (prevBtn) prevBtn.addEventListener("click", function() { - schedulerSelectRelativeEntry(-1); - }); - const nextBtn = schedulerEl("scheduler-next-btn"); - if (nextBtn) nextBtn.addEventListener("click", function() { - schedulerSelectRelativeEntry(1); - }); - const schPanel = schedulerEl("scheduler-panel"); - if (schPanel && !wiredElements.has(schPanel)) { - wiredElements.add(schPanel); - schPanel.addEventListener("input", function(e) { - if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; - markSchedulerDirty(); - }); - schPanel.addEventListener("change", function(e) { - if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; - markSchedulerDirty(); - }); - } + container.innerHTML = html; + }).catch(function() { + }); + } + function renderScheduler() { + const panel = schedulerEl("scheduler-panel"); + if (!panel) return; + const mode = currentConfig && currentConfig.mode || "disabled"; + const isControl = schedulerRole === "control"; + setSelected("scheduler-mode-select", mode); + const satEnabled = currentConfig && currentConfig.satellites && currentConfig.satellites.enabled; + const controlRow = document.querySelector(".scheduler-control-row"); + if (controlRow) controlRow.style.display = mode !== "disabled" || satEnabled ? "" : "none"; + const glSection = schedulerEl("scheduler-grayline-section"); + const tsSection = schedulerEl("scheduler-timespan-section"); + if (glSection) glSection.style.display = mode === "grayline" ? "" : "none"; + if (tsSection) tsSection.style.display = mode === "time_span" ? "" : "none"; + renderSatelliteSection(); + if (mode === "grayline" && currentConfig && currentConfig.grayline) { + const gl = currentConfig.grayline; + const lat = gl.lat ?? schedulerWindow.serverLat ?? ""; + const lon = gl.lon ?? schedulerWindow.serverLon ?? ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); const gridEl = schedulerEl("scheduler-gl-grid"); if (gridEl) { - gridEl.addEventListener("input", function() { - const ll = gridToLatLon(gridEl.value); - if (ll) { - setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); - setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); - markSchedulerDirty(); + gridEl.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", gl.transition_window_min != null ? gl.transition_window_min : 20); + renderBookmarkSelect("scheduler-gl-dawn", gl.dawn_bookmark_id); + renderBookmarkSelect("scheduler-gl-day", gl.day_bookmark_id); + renderBookmarkSelect("scheduler-gl-dusk", gl.dusk_bookmark_id); + renderBookmarkSelect("scheduler-gl-night", gl.night_bookmark_id); + } else if (mode === "grayline") { + const lat = schedulerWindow.serverLat ?? ""; + const lon = schedulerWindow.serverLon ?? ""; + setInputValue("scheduler-gl-lat", lat != null ? lat : ""); + setInputValue("scheduler-gl-lon", lon != null ? lon : ""); + const gridEl2 = schedulerEl("scheduler-gl-grid"); + if (gridEl2 && lat !== "" && lon !== "") { + gridEl2.value = latLonToGrid(lat, lon); + } + setInputValue("scheduler-gl-window", 20); + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } else { + renderBookmarkSelect("scheduler-gl-dawn", null); + renderBookmarkSelect("scheduler-gl-day", null); + renderBookmarkSelect("scheduler-gl-dusk", null); + renderBookmarkSelect("scheduler-gl-night", null); + } + const ilEl = schedulerEl("scheduler-ts-interleave"); + if (ilEl) { + const il = currentConfig && currentConfig.interleave_min; + ilEl.value = il ? String(il) : ""; + } + renderTimespanEntries(); + const formEls = panel.querySelectorAll("input, select, button.sch-write"); + formEls.forEach(function(el) { + el.disabled = !isControl; + }); + const saveBtn = schedulerEl("scheduler-save-btn"); + if (saveBtn) { + saveBtn.style.display = isControl ? "" : "none"; + } + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) { + resetBtn.style.display = isControl ? "" : "none"; + } + } + function setSelected(id, value) { + const el = schedulerEl(id); + if (el) el.value = value; + } + function setInputValue(id, value) { + const el = schedulerEl(id); + if (el) el.value = String(value); + } + function renderBookmarkSelect(id, selectedId) { + const sel = schedulerEl(id); + if (!sel) return; + sel.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + if (bm.id === selectedId) opt.selected = true; + sel.appendChild(opt); + }); + } + function formatFreq(hz) { + if (hz >= 1e6) return (hz / 1e6).toFixed(3) + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1) + " kHz"; + return hz + " Hz"; + } + function schOpenEntryForm(entry, idx) { + schEntryEditIdx = idx != null ? idx : null; + const titleEl = schedulerEl("sch-entry-form-title"); + if (titleEl) titleEl.textContent = entry ? "Edit Entry" : "Add Entry"; + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); + if (startEl) startEl.value = entry ? minToHHMM(entry.start_min) : ""; + if (endEl) endEl.value = entry ? minToHHMM(entry.end_min) : ""; + if (bmEl) bmEl.value = entry ? entry.bookmark_id || "" : ""; + if (labelEl) labelEl.value = entry ? entry.label || "" : ""; + if (ilEl) ilEl.value = entry?.interleave_min ? String(entry.interleave_min) : ""; + if (centerHzEl) centerHzEl.value = entry?.center_hz ? String(entry.center_hz) : ""; + const recordEl = schedulerEl("scheduler-ts-entry-record"); + if (recordEl) recordEl.checked = !!(entry && entry.record); + pendingExtraBmIds = entry && Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + renderExtraBmList(); + const wrap = schedulerEl("sch-entry-form-wrap"); + if (wrap) { + wrap.style.display = "block"; + if (startEl) startEl.focus(); + } + } + function schCloseEntryForm() { + const wrap = schedulerEl("sch-entry-form-wrap"); + if (wrap) wrap.style.display = "none"; + schEntryEditIdx = null; + pendingExtraBmIds = []; + } + function schEntryFormSubmit(e) { + e.preventDefault(); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + const bmEl = schedulerEl("scheduler-ts-bookmark"); + const labelEl = schedulerEl("scheduler-ts-label"); + const ilEl = schedulerEl("scheduler-ts-entry-interleave"); + const centerHzEl = schedulerEl("scheduler-ts-center-hz"); + if (!startEl || !endEl || !bmEl) return; + const bmId = bmEl.value; + if (!bmId) { + schedulerWindow.trxUi.notify?.("Select a primary bookmark before saving.", { kind: "error" }); + return; + } + const startMin = hhmmToMin(startEl.value); + const endMin = hhmmToMin(endEl.value); + const label = labelEl ? labelEl.value.trim() : ""; + const ilVal = ilEl ? parseInt(ilEl.value, 10) : NaN; + const entryInterleave = !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + const centerHzRaw = centerHzEl ? parseInt(centerHzEl.value, 10) : NaN; + const centerHz = !isNaN(centerHzRaw) && centerHzRaw > 0 ? centerHzRaw : null; + const extraBmIds = pendingExtraBmIds.slice(); + currentConfig ??= { remote: currentRigId, mode: "time_span", grayline: null, entries: [] }; + const config = currentConfig; + const recordCb = schedulerEl("scheduler-ts-entry-record"); + const entryRecord = recordCb ? recordCb.checked : false; + const entryData = { + id: "ts_" + Date.now().toString(36), + start_min: startMin, + end_min: endMin, + bookmark_id: bmId, + label: label || null, + interleave_min: entryInterleave, + center_hz: centerHz, + bookmark_ids: extraBmIds, + record: entryRecord, + exclusive: false + }; + if (schEntryEditIdx !== null) { + const existing = config.entries[schEntryEditIdx]; + if (existing?.id) entryData.id = existing.id; + config.entries[schEntryEditIdx] = entryData; + } else { + config.entries.push(entryData); + } + schCloseEntryForm(); + renderTimespanEntries(); + markSchedulerDirty(); + } + const TIMELINE_COLORS = ["#38bdf8", "#f59e0b", "#a78bfa", "#34d399", "#fb7185", "#60a5fa"]; + function renderTimeline() { + const container = schedulerEl("scheduler-ts-timeline"); + if (!container) return; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + if (entries.length === 0) { + container.innerHTML = ""; + return; + } + const W = 1e3; + const H = 80; + const BAR_Y = 6; + const BAR_H = 30; + const TICK_Y = BAR_Y + BAR_H + 2; + let svg = ''; + svg += ''; + entries.forEach(function(entry, idx) { + const start = Number(entry.start_min); + const end = Number(entry.end_min); + if (!Number.isFinite(start) || !Number.isFinite(end)) return; + const color = TIMELINE_COLORS[idx % TIMELINE_COLORS.length]; + if (start === end) { + svg += ''; + } else if (start < end) { + const x = start / 1440 * W; + const w = (end - start) / 1440 * W; + svg += ''; + } else { + const x1 = start / 1440 * W; + const w1 = W - x1; + svg += ''; + const w2 = end / 1440 * W; + svg += ''; + } + }); + const interleaveMin = currentConfig && currentConfig.interleave_min ? Number(currentConfig.interleave_min) : 0; + if (interleaveMin > 0 && entries.length > 1) { + for (let m = 0; m < 1440; m += interleaveMin) { + const overlapping = []; + entries.forEach(function(entry, idx) { + if (schedulerEntryIsActive(entry, m)) { + overlapping.push(idx); } }); - } - const latEl = schedulerEl("scheduler-gl-lat"); - const lonEl = schedulerEl("scheduler-gl-lon"); - [latEl, lonEl].forEach(function(el) { - if (el) { - el.addEventListener("input", function() { - const la = parseFloat(schedulerEl("scheduler-gl-lat").value); - const lo = parseFloat(schedulerEl("scheduler-gl-lon").value); - const gEl = schedulerEl("scheduler-gl-grid"); - if (gEl && !isNaN(la) && !isNaN(lo)) { - gEl.value = latLonToGrid(la, lo); - } - }); + if (overlapping.length > 1) { + const stripeX = m / 1440 * W; + const stripeW = Math.max(1, interleaveMin / 1440 * W); + const cyclePos = m % (interleaveMin * overlapping.length); + const ownerSlot = Math.floor(cyclePos / interleaveMin); + const ownerIdx = overlapping[ownerSlot % overlapping.length] ?? 0; + const stripeColor = TIMELINE_COLORS[ownerIdx % TIMELINE_COLORS.length]; + svg += ''; } - }); - wireExtraBmAdd(); - wireSatelliteEvents(); + } } - function populateTsBookmarkSelect() { - const sel = schedulerEl("scheduler-ts-bookmark"); - const extraSel = schedulerEl("scheduler-ts-extra-bm-pick"); - [sel, extraSel].forEach(function(el) { - if (!el) return; - const prev = el.value; - el.innerHTML = ''; - bookmarkList.forEach(function(bm) { - const opt = document.createElement("option"); - opt.value = bm.id; - opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; - el.appendChild(opt); - }); - if (prev) el.value = prev; - }); + for (let h = 0; h <= 24; h += 3) { + const tx = h / 24 * W; + svg += ''; + if (h < 24) { + svg += '' + String(h).padStart(2, "0") + ""; + } } - let pendingExtraBmIds = []; - function renderExtraBmList() { - const container = schedulerEl("scheduler-ts-extra-bm-list"); - if (!container) return; - container.innerHTML = ""; - pendingExtraBmIds.forEach(function(id, idx) { - const bm = bookmarkList.find(function(b) { - return b.id === id; - }); + const LOCAL_TICK_Y = TICK_Y + 18; + for (let h = 0; h < 24; h += 3) { + const localMin = h * 60; + const utcOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset(); + const utcMin = (localMin + utcOffset + 1440) % 1440; + const tx = utcMin / 1440 * W; + svg += '' + String(h).padStart(2, "0") + "L"; + } + svg += '' + timelineNeedleSvg() + ""; + svg += ""; + container.innerHTML = svg; + container.querySelectorAll(".sch-timeline-seg").forEach(function(seg) { + seg.addEventListener("click", function() { + const i = parseInt(seg.getAttribute("data-idx") ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + if (entry) schOpenEntryForm(entry, i); + }); + }); + const svgEl = container.querySelector("svg"); + if (svgEl) { + const timelineSvg = svgEl; + svgEl.addEventListener("click", function(e) { + if (e.target instanceof Element && e.target.classList.contains("sch-timeline-seg")) return; + const rect = timelineSvg.getBoundingClientRect(); + const xPct = (e.clientX - rect.left) / rect.width; + const clickMin = Math.floor(xPct * 1440); + const startHour = Math.floor(clickMin / 60); + const startMin = startHour * 60; + const endMin = (startHour + 1) % 24 * 60; + schOpenEntryForm(null); + const startEl = schedulerEl("scheduler-ts-start"); + const endEl = schedulerEl("scheduler-ts-end"); + if (startEl) startEl.value = minToHHMM(startMin); + if (endEl) endEl.value = minToHHMM(endMin); + }); + svgEl.style.cursor = "crosshair"; + } + } + function timelineNeedleSvg() { + const info = schedulerUtcMinuteInfo(); + const nowMin = info.minuteOfDay + info.secondOfMinute / 60; + const x = nowMin / 1440 * 1e3; + return ''; + } + function renderTimelineNeedle() { + const g = schedulerEl("sch-timeline-needle-g"); + if (g) g.innerHTML = timelineNeedleSvg(); + } + function schInlineEdit(tr, entry, idx) { + const bmOptions = bookmarkList.map(function(bm) { + const sel = bm.id === entry.bookmark_id ? " selected" : ""; + return '"; + }).join(""); + const extraBmOptions = '' + bookmarkList.map(function(bm) { + return '"; + }).join(""); + const inlineExtraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids.slice() : []; + tr.innerHTML = '⠇' + (entry.center_hz ? formatFreq(entry.center_hz) : "—") + '
'; + tr.classList.add("sch-inline-editing"); + const chipsContainer = tr.querySelector(".sch-inline-extra-chips"); + const extraPick = tr.querySelector(".sch-inline-extra-pick"); + const extraAddBtn = tr.querySelector(".sch-inline-extra-add"); + if (!chipsContainer || !extraPick || !extraAddBtn) return; + const chips = chipsContainer; + const picker = extraPick; + function renderInlineExtraChips() { + chips.innerHTML = ""; + inlineExtraIds.forEach(function(id, i) { const chip = document.createElement("span"); chip.className = "sch-extra-bm-chip"; const rmBtn = document.createElement("span"); @@ -1130,94 +654,567 @@ rmBtn.textContent = "×"; rmBtn.title = "Remove"; rmBtn.addEventListener("click", function() { - pendingExtraBmIds.splice(idx, 1); - renderExtraBmList(); + inlineExtraIds.splice(i, 1); + renderInlineExtraChips(); }); chip.appendChild(rmBtn); - const label = document.createTextNode(" " + (bm ? bm.name : id)); - chip.appendChild(label); - container.appendChild(chip); + chip.appendChild(document.createTextNode(" " + bmName(id))); + chips.appendChild(chip); }); - const pick = schedulerEl("scheduler-ts-extra-bm-pick"); - if (pick) { - Array.from(pick.options).forEach(function(opt) { - if (opt.value) { - opt.disabled = pendingExtraBmIds.includes(opt.value); + Array.from(picker.options).forEach(function(opt) { + if (opt.value) opt.disabled = inlineExtraIds.includes(opt.value); + }); + } + renderInlineExtraChips(); + extraAddBtn.addEventListener("click", function() { + if (!picker.value) return; + if (!inlineExtraIds.includes(picker.value)) { + inlineExtraIds.push(picker.value); + renderInlineExtraChips(); + } + picker.value = ""; + }); + const exclEl = tr.querySelector('[data-field="exclusive"]'); + const ilInput = tr.querySelector('[data-field="interleave"]'); + if (exclEl && ilInput) { + const exclusiveInput = exclEl; + const interleaveInput = ilInput; + exclEl.addEventListener("change", function() { + interleaveInput.disabled = exclusiveInput.checked; + if (exclusiveInput.checked) interleaveInput.value = ""; + }); + } + tr.querySelector(".sch-inline-save")?.addEventListener("click", function() { + const startEl = tr.querySelector('[data-field="start"]'); + const endEl = tr.querySelector('[data-field="end"]'); + const bmEl = tr.querySelector('[data-field="bookmark"]'); + const labelEl = tr.querySelector('[data-field="label"]'); + const ilEl = tr.querySelector('[data-field="interleave"]'); + const recEl = tr.querySelector('[data-field="record"]'); + const exEl = tr.querySelector('[data-field="exclusive"]'); + if (!startEl || !endEl || !bmEl || !labelEl || !ilEl || !recEl) return; + if (bmEl && !bmEl.value) { + schedulerWindow.trxUi.notify?.("Select a bookmark before saving.", { kind: "error" }); + bmEl.focus(); + return; + } + entry.start_min = hhmmToMin(startEl.value); + entry.end_min = hhmmToMin(endEl.value); + entry.bookmark_id = bmEl.value; + entry.label = labelEl.value.trim() || null; + entry.exclusive = exEl ? exEl.checked : false; + const ilVal = parseInt(ilEl.value, 10); + entry.interleave_min = entry.exclusive ? null : !isNaN(ilVal) && ilVal > 0 ? ilVal : null; + entry.bookmark_ids = inlineExtraIds.slice(); + entry.record = recEl.checked; + if (currentConfig) currentConfig.entries[idx] = entry; + renderTimespanEntries(); + markSchedulerDirty(); + }); + tr.querySelector(".sch-inline-cancel")?.addEventListener("click", function() { + renderTimespanEntries(); + }); + } + function renderTimespanEntries() { + const tbody = schedulerEl("scheduler-ts-tbody"); + if (!tbody) return; + tbody.innerHTML = ""; + const entries = currentConfig && Array.isArray(currentConfig.entries) ? currentConfig.entries : []; + entries.forEach(function(entry, idx) { + const tr = document.createElement("tr"); + if (currentSchedulerStatus && currentSchedulerStatus.last_entry_id && entry.id && String(entry.id) === String(currentSchedulerStatus.last_entry_id)) { + tr.classList.add("sch-active"); + } + const il = entry.exclusive ? "Exclusive" : entry.interleave_min ? String(entry.interleave_min) + " min" : "—"; + const allDay = entry.start_min === entry.end_min; + const centerCell = entry.center_hz ? formatFreq(entry.center_hz) : "—"; + const extraIds = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + const extraCell = extraIds.length ? extraIds.map(function(id) { + return escHtml(bmName(id)); + }).join(", ") : "—"; + tr.innerHTML = '⠇' + (allDay ? "All day" : minToHHMM(entry.start_min) + ' (' + minToLocal(entry.start_min) + ")") + "" + (allDay ? "—" : minToHHMM(entry.end_min) + ' (' + minToLocal(entry.end_min) + ")") + "" + centerCell + "" + escHtml(bmName(entry.bookmark_id)) + "" + extraCell + "" + escHtml(entry.label || "") + "" + il + "" + (entry.record ? "Yes" : "") + ''; + tbody.appendChild(tr); + }); + tbody.querySelectorAll(".sch-edit-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + const i = parseInt(btn.dataset.idx ?? "", 10); + const entry = currentConfig && currentConfig.entries ? currentConfig.entries[i] : null; + const row = btn.closest("tr"); + if (entry && row) schInlineEdit(row, entry, i); + }); + }); + tbody.querySelectorAll(".sch-remove-btn").forEach(function(btn) { + btn.addEventListener("click", function() { + removeEntry(parseInt(btn.dataset.idx ?? "", 10)); + }); + }); + (function() { + const handles = tbody.querySelectorAll(".sch-drag-handle"); + let dragIdx = null; + handles.forEach(function(handle, idx) { + const row = handle.parentElement; + if (!row) return; + const dragRow = row; + handle.addEventListener("dragstart", function(event) { + const e = event; + dragIdx = idx; + dragRow.classList.add("sch-dragging"); + if (e.dataTransfer) { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(idx)); + } + }); + dragRow.addEventListener("dragover", function(event) { + const e = event; + e.preventDefault(); + if (e.dataTransfer) e.dataTransfer.dropEffect = "move"; + dragRow.classList.add("sch-drag-over"); + }); + dragRow.addEventListener("dragleave", function() { + dragRow.classList.remove("sch-drag-over"); + }); + dragRow.addEventListener("drop", function(event) { + const e = event; + e.preventDefault(); + dragRow.classList.remove("sch-drag-over"); + if (dragIdx === null || dragIdx === idx) return; + if (!currentConfig) return; + const entries2 = currentConfig.entries; + const moved = entries2.splice(dragIdx, 1)[0]; + if (moved) entries2.splice(idx, 0, moved); + renderTimespanEntries(); + markSchedulerDirty(); + }); + handle.addEventListener("dragend", function() { + dragRow.classList.remove("sch-dragging"); + dragIdx = null; + }); + }); + })(); + renderTimeline(); + } + function bmName(id) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + return bm ? bm.name : String(id || ""); + } + function minToLocal(min) { + const now = /* @__PURE__ */ new Date(); + const utcMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); + const utcMs = utcMidnight.getTime() + min * 6e4; + const local = new Date(utcMs); + return String(local.getHours()).padStart(2, "0") + ":" + String(local.getMinutes()).padStart(2, "0"); + } + function minToHHMM(min) { + const h = Math.floor(min / 60) % 24; + const m = min % 60; + return String(h).padStart(2, "0") + ":" + String(m).padStart(2, "0"); + } + function hhmmToMin(str) { + const parts = str.split(":"); + return parseInt(parts[0] || "0", 10) * 60 + parseInt(parts[1] || "0", 10); + } + function gridToLatLon(grid) { + grid = String(grid).toUpperCase().trim(); + if (grid.length < 4) return null; + const lonField = grid.charCodeAt(0) - 65; + const latField = grid.charCodeAt(1) - 65; + const lonSquare = parseInt(grid.charAt(2), 10); + const latSquare = parseInt(grid.charAt(3), 10); + if (isNaN(lonSquare) || isNaN(latSquare) || lonField < 0 || lonField > 17 || latField < 0 || latField > 17) return null; + let lon = lonField * 20 + lonSquare * 2 - 180; + let lat = latField * 10 + latSquare * 1 - 90; + if (grid.length >= 6) { + const lonSub = grid.charCodeAt(4) - 65; + const latSub = grid.charCodeAt(5) - 65; + if (lonSub >= 0 && lonSub < 24 && latSub >= 0 && latSub < 24) { + lon += lonSub * (2 / 24) + 1 / 24; + lat += latSub * (1 / 24) + 0.5 / 24; + } + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; + } + function latLonToGrid(lat, lon) { + lon += 180; + lat += 90; + if (isNaN(lon) || isNaN(lat)) return ""; + const lonField = String.fromCharCode(65 + Math.floor(lon / 20)); + const latField = String.fromCharCode(65 + Math.floor(lat / 10)); + const lonSquare = Math.floor(lon % 20 / 2); + const latSquare = Math.floor(lat % 10); + const lonSub = String.fromCharCode(97 + Math.floor(lon % 2 / 2 * 24)); + const latSub = String.fromCharCode(97 + Math.floor(lat % 1 * 24)); + return lonField + latField + lonSquare + latSquare + lonSub + latSub; + } + function escHtml(s) { + return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + } + function schedulerSelectRelativeEntry(delta) { + const state = schedulerInterleaveState(currentConfig); + const rigId = currentRigId; + if (!rigId || schedulerStepPending || state.activeEntries.length <= 1) return; + const count = state.activeEntries.length; + const currentIndex = state.currentIndex >= 0 ? state.currentIndex : 0; + const targetIndex = (currentIndex + delta + count) % count; + const target = state.activeEntries[targetIndex]; + if (!target || !target.id) return; + const targetId = target.id; + schedulerStepPending = true; + renderSchedulerStepControls(); + Promise.resolve(schedulerWindow.vchanTakeSchedulerControl?.() ?? null).then(function() { + return apiActivateSchedulerEntry(rigId, targetId); + }).then(function(status) { + currentSchedulerStatus = status || null; + return Promise.resolve( + schedulerWindow.vchanToggleSchedulerRelease?.() ?? null + ).then(function() { + renderStatus(status); + renderSchedulerInterleaveStatus(); + showSchedulerToast("Selected " + schedulerEntryDisplayName(target) + "."); + pollStatus(); + }); + }).catch(function(error) { + console.error("scheduler entry selection failed", error); + showSchedulerToast("Scheduler entry selection failed: " + (error instanceof Error ? error.message : String(error)), true); + }).finally(function() { + schedulerStepPending = false; + renderSchedulerStepControls(); + }); + } + function removeEntry(idx) { + if (!currentConfig || !currentConfig.entries) return; + currentConfig.entries.splice(idx, 1); + renderTimespanEntries(); + markSchedulerDirty(); + } + function bookmarkExists(id) { + if (!id) return true; + return bookmarkList.some(function(bm) { + return bm.id === id; + }); + } + function saveScheduler() { + const rig = currentRigId; + if (!rig) return; + const modeEl = schedulerEl("scheduler-mode-select"); + const rawMode = modeEl ? modeEl.value : "disabled"; + const mode = rawMode === "grayline" || rawMode === "time_span" ? rawMode : "disabled"; + const config = { + remote: rig, + mode, + grayline: null, + entries: [] + }; + if (mode === "grayline") { + const lat = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lon = parseFloat(schedulerEl("scheduler-gl-lon").value); + const win = parseInt(schedulerEl("scheduler-gl-window").value, 10); + config.grayline = { + lat: isNaN(lat) ? 0 : lat, + lon: isNaN(lon) ? 0 : lon, + transition_window_min: isNaN(win) ? 20 : win, + dawn_bookmark_id: selectVal("scheduler-gl-dawn") || null, + day_bookmark_id: selectVal("scheduler-gl-day") || null, + dusk_bookmark_id: selectVal("scheduler-gl-dusk") || null, + night_bookmark_id: selectVal("scheduler-gl-night") || null + }; + } else if (mode === "time_span") { + config.entries = currentConfig && currentConfig.entries ? currentConfig.entries : []; + const ilVal = parseInt(schedulerEl("scheduler-ts-interleave").value, 10); + config.interleave_min = isNaN(ilVal) || ilVal <= 0 ? null : ilVal; + } + config.satellites = collectSatelliteConfig(); + const missingBmErrors = []; + if (mode === "grayline" && config.grayline) { + const gl = config.grayline; + const glFields = [ + ["dawn_bookmark_id", "Grayline dawn"], + ["day_bookmark_id", "Grayline day"], + ["dusk_bookmark_id", "Grayline dusk"], + ["night_bookmark_id", "Grayline night"] + ]; + glFields.forEach(function(pair) { + const bookmarkId = gl[pair[0]]; + if (typeof bookmarkId === "string" && !bookmarkExists(bookmarkId)) missingBmErrors.push(pair[1] + " (bookmark " + bookmarkId + ")"); + }); + } + if (mode === "time_span" && Array.isArray(config.entries)) { + config.entries.forEach(function(entry, idx) { + const label = entry.label || "Entry #" + (idx + 1); + if (!bookmarkExists(entry.bookmark_id)) { + missingBmErrors.push(label + " primary bookmark (" + entry.bookmark_id + ")"); + } + const extras = Array.isArray(entry.bookmark_ids) ? entry.bookmark_ids : []; + extras.forEach(function(id) { + if (!bookmarkExists(id)) { + missingBmErrors.push(label + " extra channel (" + id + ")"); + } + }); + }); + } + if (config.satellites && Array.isArray(config.satellites.entries)) { + config.satellites.entries.forEach(function(sat, idx) { + const satLabel = sat.satellite || "Satellite #" + (idx + 1); + if (!bookmarkExists(sat.bookmark_id)) { + missingBmErrors.push(satLabel + " bookmark (" + sat.bookmark_id + ")"); + } + }); + } + if (missingBmErrors.length > 0) { + showSchedulerToast("Missing bookmarks: " + missingBmErrors.join("; "), true); + return; + } + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.disabled = true; + apiPutScheduler(rig, config).then(function(saved) { + currentConfig = saved; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler saved."); + }).catch(function(error) { + showSchedulerToast("Save failed: " + (error instanceof Error ? error.message : String(error)), true); + }).finally(function() { + if (btn) btn.disabled = false; + }); + } + function selectVal(id) { + const el = schedulerEl(id); + return el ? el.value : ""; + } + async function resetScheduler() { + const rig = currentRigId; + if (!rig) return; + if (!await schedulerWindow.trxUi.confirm({ title: "Reset scheduler?", message: "This rig's scheduler configuration will be reset to Disabled.", confirmLabel: "Reset" })) return; + apiDeleteScheduler(rig).then(function() { + currentConfig = { + remote: rig, + mode: "disabled", + grayline: null, + entries: [] + }; + renderScheduler(); + clearSchedulerDirty(); + showSchedulerToast("Scheduler reset."); + }).catch(function(error) { + showSchedulerToast("Reset failed: " + (error instanceof Error ? error.message : String(error)), true); + }); + } + function markSchedulerDirty() { + if (schedulerDirty) return; + schedulerDirty = true; + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.classList.add("sch-dirty"); + } + function clearSchedulerDirty() { + schedulerDirty = false; + const btn = schedulerEl("scheduler-save-btn"); + if (btn) btn.classList.remove("sch-dirty"); + } + function showSchedulerToast(msg, isError = false) { + const el = schedulerEl("scheduler-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 wireSchedulerEvents() { + const modeEl = schedulerEl("scheduler-mode-select"); + if (modeEl) { + modeEl.addEventListener("change", function() { + const mode = modeEl.value === "grayline" || modeEl.value === "time_span" ? modeEl.value : "disabled"; + currentConfig ??= { remote: currentRigId, mode, grayline: null, entries: [] }; + currentConfig.mode = mode; + renderScheduler(); + }); + } + const saveBtn = schedulerEl("scheduler-save-btn"); + if (saveBtn) saveBtn.addEventListener("click", saveScheduler); + const resetBtn = schedulerEl("scheduler-reset-btn"); + if (resetBtn) resetBtn.addEventListener("click", () => { + void resetScheduler(); + }); + const addBtn = schedulerEl("scheduler-ts-add-btn"); + if (addBtn) addBtn.addEventListener("click", function() { + schOpenEntryForm(null); + }); + const entryForm = schedulerEl("sch-entry-form"); + if (entryForm) entryForm.addEventListener("submit", schEntryFormSubmit); + const cancelBtn = schedulerEl("sch-entry-form-cancel"); + if (cancelBtn) cancelBtn.addEventListener("click", schCloseEntryForm); + const prevBtn = schedulerEl("scheduler-prev-btn"); + if (prevBtn) prevBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(-1); + }); + const nextBtn = schedulerEl("scheduler-next-btn"); + if (nextBtn) nextBtn.addEventListener("click", function() { + schedulerSelectRelativeEntry(1); + }); + const schPanel = schedulerEl("scheduler-panel"); + if (schPanel && !wiredElements.has(schPanel)) { + wiredElements.add(schPanel); + schPanel.addEventListener("input", function(e) { + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + schPanel.addEventListener("change", function(e) { + if (!(e.target instanceof Element) || e.target.closest("#sch-entry-form") || e.target.closest("#sch-sat-form")) return; + markSchedulerDirty(); + }); + } + const gridEl = schedulerEl("scheduler-gl-grid"); + if (gridEl) { + gridEl.addEventListener("input", function() { + const ll = gridToLatLon(gridEl.value); + if (ll) { + setInputValue("scheduler-gl-lat", ll.lat.toFixed(3)); + setInputValue("scheduler-gl-lon", ll.lon.toFixed(3)); + markSchedulerDirty(); + } + }); + } + const latEl = schedulerEl("scheduler-gl-lat"); + const lonEl = schedulerEl("scheduler-gl-lon"); + [latEl, lonEl].forEach(function(el) { + if (el) { + el.addEventListener("input", function() { + const la = parseFloat(schedulerEl("scheduler-gl-lat").value); + const lo = parseFloat(schedulerEl("scheduler-gl-lon").value); + const gEl = schedulerEl("scheduler-gl-grid"); + if (gEl && !isNaN(la) && !isNaN(lo)) { + gEl.value = latLonToGrid(la, lo); } }); } - } - function wireExtraBmAdd() { - const addBtn = schedulerEl("scheduler-ts-extra-bm-add"); - if (!addBtn || wiredElements.has(addBtn)) return; - wiredElements.add(addBtn); - addBtn.addEventListener("click", function() { - const pick = schedulerEl("scheduler-ts-extra-bm-pick"); - if (!pick || !pick.value) return; - if (!pendingExtraBmIds.includes(pick.value)) { - pendingExtraBmIds.push(pick.value); - renderExtraBmList(); - } - pick.value = ""; - }); - } - function renderSatelliteSection() { - schedulerWindow.satScheduler?.renderSection(); - } - function renderSatPassStatus() { - schedulerWindow.satScheduler?.renderPassStatus(); - } - function collectSatelliteConfig() { - return schedulerWindow.satScheduler ? schedulerWindow.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; - } - function wireSatelliteEvents() { - schedulerWindow.satScheduler?.wireEvents(); - } - function isInputFocused() { - const el = document.activeElement; - if (!el) return false; - const tag = el.tagName; - return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el instanceof HTMLElement && el.isContentEditable; - } - document.addEventListener("keydown", function(e) { - if (isInputFocused()) return; - if (e.shiftKey && e.key === "R") { - e.preventDefault(); - const releaseBtn = schedulerEl("scheduler-release-btn"); - if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); - } else if (e.shiftKey && e.key === "N") { - e.preventDefault(); - schedulerSelectRelativeEntry(1); - } else if (e.shiftKey && e.key === "P") { - e.preventDefault(); - schedulerSelectRelativeEntry(-1); - } }); - (function() { - const details = document.querySelector(".sch-ts-details"); - if (!details) return; - const schedulerDetails = details; - const key = "sch-details-open"; - const saved = localStorage.getItem(key); - if (saved !== null) schedulerDetails.open = saved === "1"; - details.addEventListener("toggle", function() { - localStorage.setItem(key, schedulerDetails.open ? "1" : "0"); + wireExtraBmAdd(); + wireSatelliteEvents(); + } + function populateTsBookmarkSelect() { + const sel = schedulerEl("scheduler-ts-bookmark"); + const extraSel = schedulerEl("scheduler-ts-extra-bm-pick"); + [sel, extraSel].forEach(function(el) { + if (!el) return; + const prev = el.value; + el.innerHTML = ''; + bookmarkList.forEach(function(bm) { + const opt = document.createElement("option"); + opt.value = bm.id; + opt.textContent = bm.name + " (" + formatFreq(bm.freq_hz) + " " + bm.mode + ")"; + el.appendChild(opt); + }); + if (prev) el.value = prev; + }); + } + let pendingExtraBmIds = []; + function renderExtraBmList() { + const container = schedulerEl("scheduler-ts-extra-bm-list"); + if (!container) return; + container.innerHTML = ""; + pendingExtraBmIds.forEach(function(id, idx) { + const bm = bookmarkList.find(function(b) { + return b.id === id; + }); + const chip = document.createElement("span"); + chip.className = "sch-extra-bm-chip"; + const rmBtn = document.createElement("span"); + rmBtn.className = "sch-extra-bm-chip-rm"; + rmBtn.textContent = "×"; + rmBtn.title = "Remove"; + rmBtn.addEventListener("click", function() { + pendingExtraBmIds.splice(idx, 1); + renderExtraBmList(); + }); + chip.appendChild(rmBtn); + const label = document.createTextNode(" " + (bm ? bm.name : id)); + chip.appendChild(label); + container.appendChild(chip); + }); + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); + if (pick) { + Array.from(pick.options).forEach(function(opt) { + if (opt.value) { + opt.disabled = pendingExtraBmIds.includes(opt.value); + } }); - })(); - const schedulerService = { - initialize: initScheduler, - destroy: destroyScheduler, - wireEvents: wireSchedulerEvents, - setRig: setSchedulerRig, - getConfig: () => currentConfig, - getStatus: () => currentSchedulerStatus, - getBookmarks: () => bookmarkList, - markDirty: markSchedulerDirty - }; - schedulerWindow.trx.modules.scheduler = schedulerService; - if (schedulerWindow.authRole != null) { - initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); - wireSchedulerEvents(); } + } + function wireExtraBmAdd() { + const addBtn = schedulerEl("scheduler-ts-extra-bm-add"); + if (!addBtn || wiredElements.has(addBtn)) return; + wiredElements.add(addBtn); + addBtn.addEventListener("click", function() { + const pick = schedulerEl("scheduler-ts-extra-bm-pick"); + if (!pick || !pick.value) return; + if (!pendingExtraBmIds.includes(pick.value)) { + pendingExtraBmIds.push(pick.value); + renderExtraBmList(); + } + pick.value = ""; + }); + } + function renderSatelliteSection() { + schedulerWindow.satScheduler?.renderSection(); + } + function renderSatPassStatus() { + schedulerWindow.satScheduler?.renderPassStatus(); + } + function collectSatelliteConfig() { + return schedulerWindow.satScheduler ? schedulerWindow.satScheduler.collectSatelliteConfig() : { enabled: false, pretune_secs: 60, entries: [] }; + } + function wireSatelliteEvents() { + schedulerWindow.satScheduler?.wireEvents(); + } + function isInputFocused() { + const el = document.activeElement; + if (!el) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el instanceof HTMLElement && el.isContentEditable; + } + document.addEventListener("keydown", function(e) { + if (isInputFocused()) return; + if (e.shiftKey && e.key === "R") { + e.preventDefault(); + const releaseBtn = schedulerEl("scheduler-release-btn"); + if (releaseBtn && !releaseBtn.disabled) releaseBtn.click(); + } else if (e.shiftKey && e.key === "N") { + e.preventDefault(); + schedulerSelectRelativeEntry(1); + } else if (e.shiftKey && e.key === "P") { + e.preventDefault(); + schedulerSelectRelativeEntry(-1); + } + }); + (function() { + const details = document.querySelector(".sch-ts-details"); + if (!details) return; + const schedulerDetails = details; + const key = "sch-details-open"; + const saved = localStorage.getItem(key); + if (saved !== null) schedulerDetails.open = saved === "1"; + details.addEventListener("toggle", function() { + localStorage.setItem(key, schedulerDetails.open ? "1" : "0"); + }); })(); + const schedulerService = { + initialize: initScheduler, + destroy: destroyScheduler, + wireEvents: wireSchedulerEvents, + setRig: setSchedulerRig, + getConfig: () => currentConfig, + getStatus: () => currentSchedulerStatus, + getBookmarks: () => bookmarkList, + markDirty: markSchedulerDirty + }; + schedulerWindow.trx.modules.scheduler = schedulerService; + if (schedulerWindow.authRole != null) { + initScheduler(schedulerWindow.lastActiveRigId ?? null, schedulerWindow.authRole); + wireSchedulerEvents(); + } })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js index 41be5773..07b4edca 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vchan.js @@ -1,459 +1,456 @@ -"use strict"; -(() => { - // src/plugins/vchan.ts - var vchanWindow = window; - var vchanSessionId = null; - var vchanRigId = null; - var vchanChannels = []; - var vchanActiveId = null; - var schedulerReleaseState = null; - var schedulerReleasePollTimer = null; - function vchanFmtFreq(hz) { - if (!Number.isFinite(hz) || hz <= 0) return "--"; - if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz"; - if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz"; - if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; - return `${String(hz)} Hz`; +// src/plugins/vchan.ts +var vchanWindow = window; +var vchanSessionId = null; +var vchanRigId = null; +var vchanChannels = []; +var vchanActiveId = null; +var schedulerReleaseState = null; +var schedulerReleasePollTimer = null; +function vchanFmtFreq(hz) { + if (!Number.isFinite(hz) || hz <= 0) return "--"; + if (hz >= 1e9) return (hz / 1e9).toFixed(4).replace(/\.?0+$/, "") + " GHz"; + if (hz >= 1e6) return (hz / 1e6).toFixed(4).replace(/\.?0+$/, "") + " MHz"; + if (hz >= 1e3) return (hz / 1e3).toFixed(1).replace(/\.?0+$/, "") + " kHz"; + return `${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) return "Scheduler is controlling the rig."; - const connected = Number(state.connected_sessions) || 0; - const released = Number(state.released_sessions) || 0; - if (connected === 0) return "Scheduler can control the rig."; - if (state.all_released) { - return connected === 1 ? "Scheduler is controlling the rig." : `Scheduler is controlling the rig for all ${connected} users.`; + if (!state.current_session_released) { + const othersReleased = Math.max(released, 0); + return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; + } + const blocking = Math.max(connected - released, 0); + return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; +} +function vchanRenderSchedulerRelease() { + const btn = document.getElementById("scheduler-release-btn"); + const status = document.getElementById("scheduler-release-status"); + if (!btn || !status) return; + const currentReleased = !!(schedulerReleaseState && schedulerReleaseState.current_session_released); + btn.disabled = !vchanSessionId || currentReleased; + btn.classList.toggle("active", !currentReleased); + btn.textContent = "Release to Scheduler"; + status.textContent = schedulerReleaseSummaryText(schedulerReleaseState); +} +async function vchanPollSchedulerRelease() { + if (!vchanSessionId) { + schedulerReleaseState = null; + vchanRenderSchedulerRelease(); + return; + } + try { + const resp = await fetch(`/scheduler-control?session_id=${encodeURIComponent(vchanSessionId)}`); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + schedulerReleaseState = await resp.json(); + vchanRenderSchedulerRelease(); + } catch (e) { + console.error("scheduler release status failed", e); + } +} +function vchanStartSchedulerReleasePolling() { + if (schedulerReleasePollTimer) { + clearInterval(schedulerReleasePollTimer); + } + schedulerReleasePollTimer = setInterval(() => { + 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) { - const othersReleased = Math.max(released, 0); - return othersReleased > 0 ? `You are holding control. ${othersReleased} other user${othersReleased === 1 ? "" : "s"} already released it.` : "You are holding control. Release it to return control to the scheduler."; + 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); } - const blocking = Math.max(connected - released, 0); - return blocking > 0 ? `Scheduler is waiting for ${blocking} user${blocking === 1 ? "" : "s"} to stop manual tuning.` : "Scheduler can control the rig."; + 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()); } - 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(); + 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", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }) + }); + if (!resp.ok) { + const msg = await resp.text().catch(() => String(resp.status)); + console.warn("vchan: allocate failed —", msg); return; } - 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); - } + const ch = await resp.json(); + vchanActiveId = ch.id; + vchanRender(); + vchanReconnectAudio(); + } catch (e) { + console.error("vchan: allocate error", e); } - function vchanStartSchedulerReleasePolling() { - if (schedulerReleasePollTimer) { - clearInterval(schedulerReleasePollTimer); +} +async function vchanDelete(channelId) { + if (!vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`, + { method: "DELETE" } + ); + if (!resp.ok) { + console.warn("vchan: delete failed", resp.status); } - schedulerReleasePollTimer = setInterval(() => { - void vchanPollSchedulerRelease(); - }, 1e4); + } catch (e) { + console.error("vchan: delete error", e); } - 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(); - } - 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)}`, { +} +async function vchanAutoJoinPrimary(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId, freq_hz: freqHz, mode }) - }); - if (!resp.ok) { - const msg = await resp.text().catch(() => String(resp.status)); - console.warn("vchan: allocate failed —", msg); - return; + body: JSON.stringify({ session_id: vchanSessionId }) } - const ch = await resp.json(); - vchanActiveId = ch.id; - vchanRender(); - vchanReconnectAudio(); - } catch (e) { - console.error("vchan: allocate error", e); + ); + if (!resp.ok) { + console.warn("vchan: auto-join primary failed", resp.status); + return; } + vchanActiveId = channelId; + vchanRender(); + } catch (e) { + console.error("vchan: auto-join error", e); } - async function vchanDelete(channelId) { - if (!vchanRigId) return; - try { - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}`, - { method: "DELETE" } - ); - if (!resp.ok) { - console.warn("vchan: delete failed", resp.status); +} +async function vchanSubscribe(channelId) { + if (!vchanSessionId || !vchanRigId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: vchanSessionId }) } - } 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; - try { - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId }) - } - ); - if (!resp.ok) { - console.warn("vchan: auto-join primary failed", resp.status); - return; - } - vchanActiveId = channelId; - vchanRender(); - } catch (e) { - console.error("vchan: auto-join error", e); - } +} +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(); + 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; - try { - await vchanTakeSchedulerControl(); - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(channelId)}/subscribe`, - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ session_id: vchanSessionId }) - } - ); - if (!resp.ok) { - console.warn("vchan: subscribe failed", resp.status); - return; - } - vchanActiveId = channelId; - vchanRender(); - vchanSyncModeDisplay(); - vchanReconnectAudio(); - } catch (e) { - console.error("vchan: subscribe error", e); - } - } - function vchanReconnectAudio() { - const ch = vchanIsOnVirtual() ? vchanActiveChannel() : null; - 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() { +} +function vchanSyncModeDisplay() { + const modeEl = document.getElementById("mode"); + if (!modeEl) return; + if (vchanIsOnVirtual()) { 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+$/, ""); - } + if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); } - function vchanSyncModeDisplay() { - const modeEl = document.getElementById("mode"); - if (!modeEl) return; - if (vchanIsOnVirtual()) { - const ch = vchanActiveChannel(); - if (ch && ch.mode) modeEl.value = ch.mode.toUpperCase(); + const modeUpper = (modeEl.value || "").toUpperCase(); + if (typeof vchanWindow.lastModeName === "string") { + if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { + vchanWindow.setJogDivisor?.(10); + vchanWindow.resetRdsDisplay?.(); + } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { + vchanWindow.resetRdsDisplay?.(); } - const modeUpper = (modeEl.value || "").toUpperCase(); - if (typeof vchanWindow.lastModeName === "string") { - if (modeUpper === "WFM" && vchanWindow.lastModeName !== "WFM") { - vchanWindow.setJogDivisor?.(10); - vchanWindow.resetRdsDisplay?.(); - } else if (modeUpper !== "WFM" && vchanWindow.lastModeName === "WFM") { - vchanWindow.resetRdsDisplay?.(); + vchanWindow.lastModeName = modeUpper; + } + vchanWindow.updateWfmControls?.(); + vchanWindow.updateSdrSquelchControlVisibility?.(); + if (vchanWindow.refreshRdsUi) { + vchanWindow.refreshRdsUi(); + } 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; - } - vchanWindow.updateWfmControls?.(); - vchanWindow.updateSdrSquelchControlVisibility?.(); - if (vchanWindow.refreshRdsUi) { - vchanWindow.refreshRdsUi(); - } else { - vchanWindow.positionRdsPsOverlay?.(); + return; } } - 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; + void vchanTakeSchedulerControl(); + void fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ freq_hz: Math.round(freqHz) }) } - 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 - ); - } - return; - } - } - void vchanTakeSchedulerControl(); - void fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/freq`, + ).catch((error) => { + console.error("vchan: set freq error", error); + }); +} +async function vchanSetChannelBandwidth(bwHz) { + if (!vchanRigId || !vchanActiveId) return; + try { + await vchanTakeSchedulerControl(); + const resp = await fetch( + `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, { method: "PUT", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ 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) { - if (!vchanRigId || !vchanActiveId) return; - try { - await vchanTakeSchedulerControl(); - const resp = await fetch( - `/channels/${encodeURIComponent(vchanRigId)}/${encodeURIComponent(vchanActiveId)}/bw`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ bandwidth_hz: Math.round(bwHz) }) - } - ); - if (!resp.ok) console.warn("vchan: set bw failed", resp.status); - } catch (e) { - console.error("vchan: set bw error", e); + vchanStartSchedulerReleasePolling(); + vchanRenderSchedulerRelease(); +})(); +(function() { + origRefreshFreqDisplay = vchanWindow.refreshFreqDisplay ?? null; + vchanWindow.refreshFreqDisplay = function() { + if (vchanIsOnVirtual()) { + vchanUpdateFreqDisplay(); + return; } - } - 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; + origRefreshFreqDisplay?.(); }; - (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?.(); - }; - })(); })(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js index 19338739..eb756fcf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/vdes.js @@ -1,271 +1,268 @@ -"use strict"; -(() => { - // src/plugins/vdes.ts - var vdesWindow = window; - var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); - var vdesStatus = document.getElementById("vdes-status"); - var vdesMessagesEl = document.getElementById("vdes-messages"); - var vdesFilterInput = document.getElementById("vdes-filter"); - var vdesBarOverlay = document.getElementById("vdes-bar-overlay"); - var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); - var vdesFrameCountEl = document.getElementById("vdes-frame-count"); - var vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); - var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3; - var vdesFilterText = ""; - var vdesMessageHistory = []; - function currentVdesHistoryRetentionMs() { - return typeof vdesWindow.getDecodeHistoryRetentionMs === "function" ? vdesWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +// src/plugins/vdes.ts +var vdesWindow = window; +var escapeVdesHtml = (input) => vdesWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +var vdesStatus = document.getElementById("vdes-status"); +var vdesMessagesEl = document.getElementById("vdes-messages"); +var vdesFilterInput = document.getElementById("vdes-filter"); +var vdesBarOverlay = document.getElementById("vdes-bar-overlay"); +var vdesChannelSummaryEl = document.getElementById("vdes-channel-summary"); +var vdesFrameCountEl = document.getElementById("vdes-frame-count"); +var vdesLatestSeenEl = document.getElementById("vdes-latest-seen"); +var VDES_BAR_WINDOW_MS = 15 * 60 * 1e3; +var vdesFilterText = ""; +var vdesMessageHistory = []; +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() { - 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; - } - 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 = `
${ts}${escapeVdesHtml(title)}${escapeVdesHtml(label)}` + (labelText ? `${escapeVdesHtml(labelText)}` : "") + (linkText ? `${escapeVdesHtml(linkText)}` : "") + (srcText ? `${escapeVdesHtml(srcText)}` : "") + (dstText ? `${escapeVdesHtml(dstText)}` : "") + (syncText ? `${escapeVdesHtml(syncText)}` : "") + (phaseText ? `${escapeVdesHtml(phaseText)}` : "") + `T${escapeVdesHtml(String(msg.message_type ?? "--"))}
${escapeVdesHtml(currentVdesCenterText())}${escapeVdesHtml(`${msg.bit_len || 0} bits`)}` + (sessionText ? `${escapeVdesHtml(sessionText)}` : "") + (asmText ? `${escapeVdesHtml(asmText)}` : "") + (countText ? `${escapeVdesHtml(countText)}` : "") + (ackText ? `${escapeVdesHtml(ackText)}` : "") + (cqiText ? `${escapeVdesHtml(cqiText)}` : "") + (info ? `${escapeVdesHtml(info)}` : "") + (fecText ? `${escapeVdesHtml(fecText)}` : "") + `${escapeVdesHtml(vdesAgeText(msg._tsMs))}
` + (previewText ? `${escapeVdesHtml(previewText)}` : "") + (previewText ? `·` : "") + `${escapeVdesHtml(rawHex)}
`; - 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 = `
VDESLiveClearLast 15 minutes
`; - for (const msg of messages) { - const ts = msg._ts ? `${msg._ts}` : ""; - 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 += `
${ts}${title} ${label}: ${detail}
`; - } - vdesBarOverlay.innerHTML = html; - vdesBarOverlay.style.display = "flex"; - } - vdesWindow.updateVdesBar = updateVdesBar; - vdesWindow.clearVdesBar = function() { - resetVdesHistoryView(); - }; - function resetVdesHistoryView() { - if (vdesMessagesEl) vdesMessagesEl.innerHTML = ""; - vdesMessageHistory = []; - updateVdesBar(); + 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(); } - function renderVdesHistory() { - pruneVdesMessageHistory(); - if (!vdesMessagesEl) { - updateVdesSummary(); - return; - } - const fragment = document.createDocumentFragment(); - for (const message of vdesMessageHistory) { - fragment.appendChild(renderVdesRow(message)); - } - vdesMessagesEl.replaceChildren(fragment); + 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 = `
${ts}${escapeVdesHtml(title)}${escapeVdesHtml(label)}` + (labelText ? `${escapeVdesHtml(labelText)}` : "") + (linkText ? `${escapeVdesHtml(linkText)}` : "") + (srcText ? `${escapeVdesHtml(srcText)}` : "") + (dstText ? `${escapeVdesHtml(dstText)}` : "") + (syncText ? `${escapeVdesHtml(syncText)}` : "") + (phaseText ? `${escapeVdesHtml(phaseText)}` : "") + `T${escapeVdesHtml(String(msg.message_type ?? "--"))}
${escapeVdesHtml(currentVdesCenterText())}${escapeVdesHtml(`${msg.bit_len || 0} bits`)}` + (sessionText ? `${escapeVdesHtml(sessionText)}` : "") + (asmText ? `${escapeVdesHtml(asmText)}` : "") + (countText ? `${escapeVdesHtml(countText)}` : "") + (ackText ? `${escapeVdesHtml(ackText)}` : "") + (cqiText ? `${escapeVdesHtml(cqiText)}` : "") + (info ? `${escapeVdesHtml(info)}` : "") + (fecText ? `${escapeVdesHtml(fecText)}` : "") + `${escapeVdesHtml(vdesAgeText(msg._tsMs))}
` + (previewText ? `${escapeVdesHtml(previewText)}` : "") + (previewText ? `·` : "") + `${escapeVdesHtml(rawHex)}
`; + 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 = `
VDESLiveClearLast 15 minutes
`; + for (const msg of messages) { + const ts = msg._ts ? `${msg._ts}` : ""; + 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 += `
${ts}${title} ${label}: ${detail}
`; + } + 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(); + return; } - 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([], { + const fragment = document.createDocumentFragment(); + for (const message of vdesMessageHistory) { + fragment.appendChild(renderVdesRow(message)); + } + 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", 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", - 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) { vdesWindow.vdesMapAddPoint(next); } + normalized.push(next); } - function pruneVdesHistoryView() { - pruneVdesMessageHistory(); - updateVdesBar(); + 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(); - } - 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 +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js index f3934838..09bae50f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wefax.js @@ -1,335 +1,332 @@ -"use strict"; -(() => { - // src/plugins/wefax.ts - var wefaxWindow = window; - var wefaxDom = { - status: document.getElementById("wefax-status"), - liveView: document.getElementById("wefax-live-view"), - historyView: document.getElementById("wefax-history-view"), - liveContainer: document.getElementById("wefax-live-container"), - liveInfo: document.getElementById("wefax-live-info"), - liveCanvas: document.getElementById("wefax-live-canvas"), - liveLatest: document.getElementById("wefax-live-latest"), - historyList: document.getElementById("wefax-history-list"), - historyCount: document.getElementById("wefax-history-count"), - filterInput: document.getElementById("wefax-filter"), - sortSelect: document.getElementById("wefax-sort"), - toggleBtn: document.getElementById("wefax-decode-toggle-btn"), - clearBtn: document.getElementById("wefax-clear-btn"), - viewLiveBtn: document.getElementById("wefax-view-live"), - viewHistoryBtn: document.getElementById("wefax-view-history") - }; - var wefaxImageHistory = []; - var WEFAX_MAX_IMAGES = 100; - var wefaxLiveCtx = null; - var wefaxLiveLineCount = 0; - var wefaxLivePixelsPerLine = 1809; - var wefaxActiveView = "live"; - var wefaxFilterText = ""; - function currentWefaxHistoryRetentionMs() { - return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3; - } - function pruneWefaxHistory() { - const cutoff = Date.now() - currentWefaxHistoryRetentionMs(); - wefaxImageHistory = wefaxImageHistory.filter(function(m) { - return (m._tsMs || 0) > cutoff; - }); - } - function escapeHtml(s) { - return String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); - } - 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"); +// src/plugins/wefax.ts +var wefaxWindow = window; +var wefaxDom = { + status: document.getElementById("wefax-status"), + liveView: document.getElementById("wefax-live-view"), + historyView: document.getElementById("wefax-history-view"), + liveContainer: document.getElementById("wefax-live-container"), + liveInfo: document.getElementById("wefax-live-info"), + liveCanvas: document.getElementById("wefax-live-canvas"), + liveLatest: document.getElementById("wefax-live-latest"), + historyList: document.getElementById("wefax-history-list"), + historyCount: document.getElementById("wefax-history-count"), + filterInput: document.getElementById("wefax-filter"), + sortSelect: document.getElementById("wefax-sort"), + toggleBtn: document.getElementById("wefax-decode-toggle-btn"), + clearBtn: document.getElementById("wefax-clear-btn"), + viewLiveBtn: document.getElementById("wefax-view-live"), + viewHistoryBtn: document.getElementById("wefax-view-history") +}; +var wefaxImageHistory = []; +var WEFAX_MAX_IMAGES = 100; +var wefaxLiveCtx = null; +var wefaxLiveLineCount = 0; +var wefaxLivePixelsPerLine = 1809; +var wefaxActiveView = "live"; +var wefaxFilterText = ""; +function currentWefaxHistoryRetentionMs() { + return wefaxWindow.getDecodeHistoryRetentionMs?.() ?? 24 * 60 * 60 * 1e3; +} +function pruneWefaxHistory() { + const cutoff = Date.now() - currentWefaxHistoryRetentionMs(); + wefaxImageHistory = wefaxImageHistory.filter(function(m) { + return (m._tsMs || 0) > cutoff; }); - if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() { - switchWefaxView("history"); +} +function escapeHtml(s) { + return String(s).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) { - const canvas = wefaxDom.liveCanvas; - if (!canvas) return; - wefaxLivePixelsPerLine = pixelsPerLine; - wefaxLiveLineCount = 0; - canvas.width = pixelsPerLine; - canvas.height = 800; + if (view === "live" && wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.classList.add("sat-view-active"); + if (view === "history" && wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.classList.add("sat-view-active"); + if (view === "history") renderWefaxHistoryTable(); +} +if (wefaxDom.viewLiveBtn) wefaxDom.viewLiveBtn.addEventListener("click", function() { + switchWefaxView("live"); +}); +if (wefaxDom.viewHistoryBtn) wefaxDom.viewHistoryBtn.addEventListener("click", function() { + switchWefaxView("history"); +}); +function resetLiveCanvas(pixelsPerLine) { + 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"); if (!wefaxLiveCtx) return; - wefaxLiveCtx.fillStyle = "#000"; - wefaxLiveCtx.fillRect(0, 0, canvas.width, canvas.height); - if (wefaxDom.liveContainer) wefaxDom.liveContainer.style.display = ""; + wefaxLiveCtx.putImageData(old, 0, 0); } - 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"); - if (!wefaxLiveCtx) return; - wefaxLiveCtx.putImageData(old, 0, 0); - } - 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++; + 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; } - function renderWefaxLatestCard() { - if (!wefaxDom.liveLatest) return; - if (wefaxImageHistory.length === 0) { - wefaxDom.liveLatest.innerHTML = '
No images decoded yet. Enable the decoder and tune to a WEFAX station.
'; - return; - } - const img = wefaxImageHistory[0]; - if (!img) 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 = '
'; - html += '
Latest decoded image
'; - html += '
' + escapeHtml(meta) + "
"; - if (imgSrc) { - html += 'View full image'; - } - html += "
"; - wefaxDom.liveLatest.innerHTML = html; + wefaxLiveCtx.putImageData(imgData, 0, y); + wefaxLiveLineCount++; +} +function renderWefaxLatestCard() { + if (!wefaxDom.liveLatest) return; + if (wefaxImageHistory.length === 0) { + wefaxDom.liveLatest.innerHTML = '
No images decoded yet. Enable the decoder and tune to a WEFAX station.
'; + return; } - function getWefaxFilteredHistory() { - let items = wefaxImageHistory; - if (wefaxFilterText) { - items = items.filter(function(i) { - const haystack = [ - String(i.ioc || ""), - String(i.lpm || ""), - String(i.line_count || "") - ].join(" ").toUpperCase(); - return haystack.indexOf(wefaxFilterText) >= 0; - }); - } - const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest"; - if (sortVal === "oldest") items = items.slice().reverse(); - return items; + const img = wefaxImageHistory[0]; + if (!img) 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 = '
'; + html += '
Latest decoded image
'; + html += '
' + escapeHtml(meta) + "
"; + if (imgSrc) { + html += 'View full image'; } - function renderWefaxHistoryRow(img) { - const row = document.createElement("div"); - row.className = "sat-history-row"; - const ts = img._ts || "--"; - const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : ""; - const ioc = img.ioc || "--"; - const lpm = img.lpm || "--"; - const lines = img.line_count || 0; - const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null; - const link = imgSrc ? 'View' : "--"; - row.innerHTML = [ - "" + escapeHtml(date + " " + ts) + "", - "" + escapeHtml(String(ioc)) + "", - "" + escapeHtml(String(lpm)) + "", - `${String(lines)}`, - "" + link + "" - ].join(""); - return row; + html += "
"; + wefaxDom.liveLatest.innerHTML = html; +} +function getWefaxFilteredHistory() { + let items = wefaxImageHistory; + if (wefaxFilterText) { + items = items.filter(function(i) { + const haystack = [ + String(i.ioc || ""), + String(i.lpm || ""), + String(i.line_count || "") + ].join(" ").toUpperCase(); + return haystack.indexOf(wefaxFilterText) >= 0; + }); } - 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`; + const sortVal = wefaxDom.sortSelect ? wefaxDom.sortSelect.value : "newest"; + if (sortVal === "oldest") items = items.slice().reverse(); + return items; +} +function renderWefaxHistoryRow(img) { + const row = document.createElement("div"); + row.className = "sat-history-row"; + const ts = img._ts || "--"; + const date = img._tsMs ? new Date(img._tsMs).toLocaleDateString([], { month: "short", day: "numeric" }) : ""; + const ioc = img.ioc || "--"; + const lpm = img.lpm || "--"; + const lines = img.line_count || 0; + const imgSrc = img._dataUrl ? img._dataUrl : img.path ? "/images/" + escapeHtml(img.path.split("/").pop()) : null; + const link = imgSrc ? 'View' : "--"; + row.innerHTML = [ + "" + escapeHtml(date + " " + ts) + "", + "" + escapeHtml(String(ioc)) + "", + "" + escapeHtml(String(lpm)) + "", + `${String(lines)}`, + "" + link + "" + ].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) { - const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); - msg._tsMs = tsMs; - msg._ts = new Date(tsMs).toLocaleTimeString([], { + 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) { + 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", 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 { - } - } - 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) { - 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", - 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); - } - })(); - }); + wefaxImageHistory = messages.concat(wefaxImageHistory); + pruneWefaxHistory(); + scheduleWefaxUi("wefax-latest", renderWefaxLatestCard); + if (wefaxActiveView === "history") { + scheduleWefaxUi("wefax-history", renderWefaxHistoryTable); } +} +function pruneWefaxHistoryView() { + pruneWefaxHistory(); + renderWefaxHistoryTable(); renderWefaxLatestCard(); - wefaxWindow.trxPluginRuntime.registerDecoder({ - id: "wefax", - onMessage: onServerWefax, - restore: restoreWefaxHistory, - prune: pruneWefaxHistoryView, - reset: resetWefaxHistoryView +} +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); }); - wefaxWindow.trxPluginRuntime.registerDecoder({ - id: "wefax_progress", - onMessage: onServerWefaxProgress +} +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); + } + })(); + }); +} +renderWefaxLatestCard(); +wefaxWindow.trxPluginRuntime.registerDecoder({ + id: "wefax", + onMessage: onServerWefax, + restore: restoreWefaxHistory, + prune: pruneWefaxHistoryView, + reset: resetWefaxHistoryView +}); +wefaxWindow.trxPluginRuntime.registerDecoder({ + id: "wefax_progress", + onMessage: onServerWefaxProgress +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js index 81f1ad59..60f26a9f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/wspr.js @@ -1,255 +1,105 @@ -"use strict"; -(() => { - // src/plugins/wspr.ts - var wsprWindow = window; - var wsprStatus = document.getElementById("wspr-status"); - var wsprPeriodEl = document.getElementById("wspr-period"); - var wsprMessagesEl = document.getElementById("wspr-messages"); - var wsprFilterInput = document.getElementById("wspr-filter"); - var WSPR_PERIOD_SECONDS = 120; - var wsprFilterText = ""; - var wsprMessageHistory = []; - function finiteNumber(value) { - const number = typeof value === "number" ? value : Number(value); - return Number.isFinite(number) ? number : null; - } - function currentWsprHistoryRetentionMs() { - return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; - } - function pruneWsprMessageHistory() { - const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); - wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs); - } - function scheduleWsprHistoryRender() { - if (typeof wsprWindow.trxScheduleUiFrameJob === "function") { - wsprWindow.trxScheduleUiFrameJob("wspr-history", () => { - renderWsprHistory(); - }); - return; - } - renderWsprHistory(); - } - function fmtWsprTime(tsMs) { - if (!tsMs) return "--:--:--"; - return new Date(tsMs).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - } - function updateWsprPeriodTimer() { - if (!wsprPeriodEl) return; - const nowSec = Math.floor(Date.now() / 1e3); - const remaining = WSPR_PERIOD_SECONDS - nowSec % WSPR_PERIOD_SECONDS; - const mm = String(Math.floor(remaining / 60)).padStart(2, "0"); - const ss = String(remaining % 60).padStart(2, "0"); - wsprPeriodEl.textContent = `Next slot ${mm}:${ss}`; - } - updateWsprPeriodTimer(); - setInterval(updateWsprPeriodTimer, 500); - function renderWsprRow(msg) { - const row = document.createElement("div"); - row.className = "ft8-row"; - row.dataset.decoder = "wspr"; - const snr = finiteNumber(msg.snr_db); - const delta = finiteNumber(msg.dt_s); - const baseHz = finiteNumber(wsprWindow.ft8BaseHz); - const offsetHz = finiteNumber(msg.freq_hz); - const rfHz = offsetHz !== null && baseHz !== null ? baseHz + offsetHz : null; - const freq = rfHz?.toFixed(0) ?? "--"; - const message = msg.message ?? ""; - row.dataset.message = message.toUpperCase(); - row.innerHTML = `${fmtWsprTime(msg.ts_ms)}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${freq}${renderWsprMessage(message)}`; - 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 += `${grid}`; - } 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(); +// src/plugins/wspr.ts +var wsprWindow = window; +var wsprStatus = document.getElementById("wspr-status"); +var wsprPeriodEl = document.getElementById("wspr-period"); +var wsprMessagesEl = document.getElementById("wspr-messages"); +var wsprFilterInput = document.getElementById("wspr-filter"); +var WSPR_PERIOD_SECONDS = 120; +var wsprFilterText = ""; +var wsprMessageHistory = []; +function finiteNumber(value) { + const number = typeof value === "number" ? value : Number(value); + return Number.isFinite(number) ? number : null; +} +function currentWsprHistoryRetentionMs() { + return typeof wsprWindow.getDecodeHistoryRetentionMs === "function" ? wsprWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; +} +function pruneWsprMessageHistory() { + const cutoffMs = Date.now() - currentWsprHistoryRetentionMs(); + wsprMessageHistory = wsprMessageHistory.filter((msg) => Number(msg._tsMs ?? msg.ts_ms) >= cutoffMs); +} +function scheduleWsprHistoryRender() { + if (typeof wsprWindow.trxScheduleUiFrameJob === "function") { + wsprWindow.trxScheduleUiFrameJob("wspr-history", () => { renderWsprHistory(); }); + return; } - 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(); - }); + 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 = `${fmtWsprTime(msg.ts_ms)}${snr?.toFixed(1) ?? "--"}${delta?.toFixed(2) ?? "--"}${freq}${renderWsprMessage(message)}`; + 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"); - 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"; + 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, { @@ -257,14 +107,161 @@ ...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({ - id: "wspr", - onMessage: onServerWspr, - onBatch: onServerWsprBatch, - restore: onServerWsprBatch, - prune: pruneWsprHistoryView, - reset: resetWsprHistoryView + 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 += `${grid}`; + } 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 +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs index ac072ec0..3246eace 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs @@ -67,7 +67,10 @@ await build({ }, outdir: outputDir, bundle: true, - format: "iife", + format: "esm", + splitting: true, + entryNames: "[name]", + chunkNames: "chunk-[hash]", platform: "browser", target: "es2022", sourcemap: false, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs index a8b95aa9..6955db3c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("APRS entry normalizes positioned packets without remote symbol assets", async () => { const forwarded = []; @@ -26,7 +27,7 @@ test("APRS entry normalizes positioned packets without remote symbol assets", as console, }); 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); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs new file mode 100644 index 00000000..1b2d04ad --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs @@ -0,0 +1,19 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs index 548a8e41..e1373dc3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ftx-family.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("FT2 entry normalizes audio offsets and registers typed callbacks", async () => { let barRenderer; @@ -30,7 +31,7 @@ test("FT2 entry normalizes audio offsets and registers typed callbacks", async ( console, }); 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(source).runInContext(context); @@ -62,7 +63,7 @@ test("FT8 entry installs shared parsing without relying on script globals", asyn console, }); 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(source).runInContext(context); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs index ab061222..224bd318 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/hf-aprs.test.mjs @@ -6,6 +6,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; import vm from "node:vm"; +import { bundleEntry } from "./bundle-entry.mjs"; test("HF APRS entry uses shared typed normalization and local symbols", async () => { const window = { trxUi: { confirm: async () => true } }; @@ -22,7 +23,7 @@ test("HF APRS entry uses shared typed normalization and local symbols", async () console, }); 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); new vm.Script(runtime).runInContext(context); new vm.Script(source).runInContext(context);