CI / lint (pull_request) Successful in 2m24s
CI / test (pull_request) Successful in 8m34s
CI / test (push) Successful in 7m47s
CI / frontend (pull_request) Successful in 4m28s
CI / reuse (pull_request) Successful in 5s
CI / lint (push) Successful in 2m21s
CI / frontend (push) Successful in 3m37s
CI / reuse (push) Successful in 6s
A client connected to several rigs decodes all of them at once, and the decode stream carries every rig's traffic to every browser. The decoder panels listed all of it: a station a background rig copied on another band appeared in the APRS list next to the selected rig's, the vessel counts and the "latest seen" lines counted both, the status lines said "Receiving" because some other rig was, and the CW pane interleaved two rigs into one stream of text that read as neither. The page is about the rig the operator selected — the one whose spectrum is on screen and whose audio is playing — so each panel now shows what that rig heard: rows, counts, latest-seen, status, the live picture a WEFAX or SSTV frame is painting, and the CW pane. Nothing is dropped on the way in. The map is the whole station's view, has its own rig filter, and would empty out if the plugins stopped feeding it, so the histories still hold every rig and the map still plots them. That also means a switch loses nothing: the runtime gained a rerender hook, which the rig switch calls, and switching back brings the other rig's traffic up again. The CW pane is the exception — a running stream of text cannot be unpicked after the fact — so it starts empty on the rig switched to. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyX26FCpMQxiBoC7r5K1A7 Signed-off-by: Stan Grams <sjg@haxx.space>
376 lines
14 KiB
JavaScript
376 lines
14 KiB
JavaScript
import {
|
|
forActiveRig,
|
|
isActiveRigDecode
|
|
} from "./chunk-S57W63QN.js";
|
|
import {
|
|
hostCore,
|
|
hostState
|
|
} from "./chunk-KL66PICH.js";
|
|
|
|
// src/plugins/ais.ts
|
|
var aisWindow = window;
|
|
var escapeAisHtml = (input) => hostCore.escapeMapHtml(input);
|
|
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;
|
|
}
|
|
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 `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
|
}
|
|
function aisTypeLabel(type) {
|
|
switch (Number(type)) {
|
|
case 1:
|
|
case 2:
|
|
case 3:
|
|
return "Class A Position";
|
|
case 4:
|
|
return "Base Station";
|
|
case 5:
|
|
return "Static/Voyage";
|
|
case 18:
|
|
return "Class B Position";
|
|
case 19:
|
|
return "Class B Extended";
|
|
case 21:
|
|
return "Aid to Nav";
|
|
case 24:
|
|
return "Class B Static";
|
|
default:
|
|
return `Type ${type ?? "--"}`;
|
|
}
|
|
}
|
|
function aisAgeText(tsMs) {
|
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
|
const seconds = Math.round(deltaMs / 1e3);
|
|
if (seconds < 5) return "just now";
|
|
if (seconds < 60) return `${seconds}s ago`;
|
|
const minutes = Math.round(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.round(minutes / 60);
|
|
return `${hours}h ago`;
|
|
}
|
|
function aisMotionText(msg) {
|
|
const parts = [
|
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
|
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null
|
|
].filter(Boolean);
|
|
return parts.join(" · ");
|
|
}
|
|
function aisRouteText(msg) {
|
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
|
}
|
|
function aisDistanceText(msg) {
|
|
if (hostState.serverLat == null || hostState.serverLon == null || msg.lat == null || msg.lon == null) {
|
|
return "";
|
|
}
|
|
const distKm = hostCore.haversineKm(hostState.serverLat, hostState.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 aisRigMessages() {
|
|
return forActiveRig(aisMessageHistory);
|
|
}
|
|
function updateAisSummary() {
|
|
const plan = currentAisChannelPlan();
|
|
if (aisChannelSummaryEl) {
|
|
aisChannelSummaryEl.textContent = `A ${formatAisMhz(plan.aHz)} · B ${formatAisMhz(plan.bHz)}`;
|
|
}
|
|
const rigMessages = aisRigMessages();
|
|
const vessels = aisLatestByVessel(rigMessages);
|
|
if (aisVesselCountEl) {
|
|
const count = vessels.length;
|
|
aisVesselCountEl.textContent = `${count} vessel${count === 1 ? "" : "s"}`;
|
|
}
|
|
if (aisLatestSeenEl) {
|
|
const latest = rigMessages[0];
|
|
if (!latest) {
|
|
aisLatestSeenEl.textContent = "No traffic yet";
|
|
} else {
|
|
const channel = aisChannelInfo(latest.channel);
|
|
aisLatestSeenEl.textContent = `${channel.label} ${aisAgeText(latest._tsMs)}`;
|
|
}
|
|
}
|
|
}
|
|
function aisSummaryText(msg) {
|
|
const parts = [];
|
|
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
|
|
const motion = aisMotionText(msg);
|
|
if (motion) parts.push(motion);
|
|
const route = aisRouteText(msg);
|
|
if (route && parts.length < 2) parts.push(route);
|
|
if (!parts.length) return route || "no position reported";
|
|
return parts.join(" · ");
|
|
}
|
|
function renderAisRow(msg) {
|
|
const row = document.createElement("details");
|
|
row.className = "ais-message";
|
|
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit"
|
|
});
|
|
const name = aisDisplayName(msg);
|
|
const nameHtml = aisDisplayNameHtml(msg);
|
|
const channel = aisChannelInfo(msg.channel);
|
|
const motion = aisMotionText(msg);
|
|
const route = aisRouteText(msg);
|
|
const distance = aisDistanceText(msg);
|
|
const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
|
|
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
|
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 = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
|
|
row.querySelectorAll("[data-ais-map]").forEach((element) => {
|
|
element.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
|
|
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
|
|
aisWindow.navigateToAprsMap?.(lat, lon);
|
|
});
|
|
});
|
|
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 && isActiveRigDecode(msg.rig_id)
|
|
);
|
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
|
if (!isAis || messages.length === 0) {
|
|
aisBarOverlay.style.display = "none";
|
|
aisBarOverlay.innerHTML = "";
|
|
return;
|
|
}
|
|
let html = `<div class="aprs-bar-header"><span class="aprs-bar-title"><span class="aprs-bar-title-word">AIS</span><span class="aprs-bar-title-word">Live</span></span><span class="aprs-bar-clear-wrap"><span class="aprs-bar-clear" role="button" tabindex="0" onclick="window.clearAisBar()" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();window.clearAisBar();}" aria-label="Clear AIS overlay">Clear</span></span><span class="aprs-bar-window">Last 15 minutes</span></div>`;
|
|
for (const msg of messages) {
|
|
const ts = msg._ts ? `<span class="aprs-bar-time">${msg._ts}</span>` : "";
|
|
const pin = msg.lat != null && msg.lon != null ? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>` : "";
|
|
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
|
const channel = aisChannelInfo(msg.channel);
|
|
const distance = aisDistanceText(msg);
|
|
const details = [
|
|
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
|
escapeAisHtml(channel.label),
|
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
|
distance ? escapeAisHtml(distance) : null,
|
|
escapeAisHtml(aisAgeText(msg._tsMs))
|
|
].filter(Boolean).join(" · ");
|
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
|
}
|
|
aisBarOverlay.innerHTML = html;
|
|
aisBarOverlay.style.display = "flex";
|
|
}
|
|
aisWindow.updateAisBar = updateAisBar;
|
|
aisWindow.clearAisBar = function() {
|
|
resetAisHistoryView();
|
|
};
|
|
function resetAisHistoryView() {
|
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
|
aisMessageHistory = [];
|
|
updateAisBar();
|
|
renderAisHistory();
|
|
aisWindow.clearMapMarkersByType?.("ais");
|
|
}
|
|
function renderAisHistory() {
|
|
pruneAisMessageHistory();
|
|
if (!aisMessagesEl) {
|
|
updateAisSummary();
|
|
return;
|
|
}
|
|
const fragment = document.createDocumentFragment();
|
|
for (const message of aisRigMessages()) {
|
|
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();
|
|
plotAisMessage(msg);
|
|
}
|
|
function plotAisMessage(msg) {
|
|
if (msg.lat == null || msg.lon == null || !aisWindow.aisMapAddVessel) return;
|
|
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;
|
|
const normalized = [];
|
|
for (const msg of messages) {
|
|
const next = normalizeServerAisMessage(msg);
|
|
if (aisStatus && isActiveRigDecode(next.rig_id)) aisStatus.textContent = "Receiving";
|
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
|
next._tsMs = tsMs;
|
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit"
|
|
});
|
|
plotAisMessage(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 hostCore.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) {
|
|
const message = normalizeServerAisMessage(msg);
|
|
if (aisStatus && isActiveRigDecode(message.rig_id)) aisStatus.textContent = "Receiving";
|
|
addAisMessage(message);
|
|
}
|
|
updateAisSummary();
|
|
window.trxPluginRuntime.registerDecoder({
|
|
id: "ais",
|
|
onMessage: onServerAis,
|
|
onBatch: onServerAisBatch,
|
|
restore: onServerAisBatch,
|
|
reset: resetAisHistoryView,
|
|
prune: pruneAisHistoryView,
|
|
rerender: () => {
|
|
updateAisBar();
|
|
renderAisHistory();
|
|
},
|
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
|
syncMap: () => {
|
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
|
}
|
|
});
|