The map module is lazy: it arrives when the Map tab is first opened, which is normally long after startup restored the decode history. Until then aprsMapAddStation, aisMapAddVessel and vdesMapAddPoint are undefined, and the decoders' `if (lat != null && ... && fn)` guards quietly dropped every restored position. Nothing replayed them once the module did arrive, so the map came up empty and filled in only from decodes heard afterwards -- a station heard once was never plotted at all. A second reload appeared to fix it because the cached module then loaded early enough to win the race against the history fetch. Give DecoderPlugin an optional syncMap(), implement it for APRS, AIS and VDES over the history each already retains, and have map-core call trxPluginRuntime.syncMapAll() as it attaches. The add functions are keyed by callsign, MMSI and point, so replaying updates in place and cannot duplicate a marker; the replay runs oldest-first so tracks are rebuilt in the order they happened. Signed-off-by: Stan Grams <sjg@haxx.space>
361 lines
14 KiB
JavaScript
361 lines
14 KiB
JavaScript
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 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 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);
|
|
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 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();
|
|
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;
|
|
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"
|
|
});
|
|
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) {
|
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
addAisMessage(normalizeServerAisMessage(msg));
|
|
}
|
|
updateAisSummary();
|
|
window.trxPluginRuntime.registerDecoder({
|
|
id: "ais",
|
|
onMessage: onServerAis,
|
|
onBatch: onServerAisBatch,
|
|
restore: onServerAisBatch,
|
|
reset: resetAisHistoryView,
|
|
prune: pruneAisHistoryView,
|
|
// Oldest first, so vessel tracks are rebuilt in the order they happened.
|
|
syncMap: () => {
|
|
for (const entry of [...aisMessageHistory].reverse()) plotAisMessage(entry);
|
|
}
|
|
});
|