refactor: convert AIS plugin to TypeScript
This commit is contained in:
@@ -1,35 +1,43 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
const aisStatus = document.getElementById("ais-status");
|
(() => {
|
||||||
const aisMessagesEl = document.getElementById("ais-messages");
|
// src/plugins/ais.ts
|
||||||
const aisFilterInput = document.getElementById("ais-filter");
|
var aisWindow = window;
|
||||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
var escapeAisHtml = (input) => aisWindow.escapeMapHtml?.(input) ?? input.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
||||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
var aisStatus = document.getElementById("ais-status");
|
||||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
var aisMessagesEl = document.getElementById("ais-messages");
|
||||||
const aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
var aisFilterInput = document.getElementById("ais-filter");
|
||||||
const AIS_BAR_WINDOW_MS = 15 * 60 * 1e3;
|
var aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
const AIS_DEFAULT_A_HZ = 161975e3;
|
var aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
const AIS_CHANNEL_SPACING_HZ = 5e4;
|
var aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
let aisFilterText = "";
|
var aisLatestSeenEl = document.getElementById("ais-latest-seen");
|
||||||
let aisMessageHistory = [];
|
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() {
|
function currentAisHistoryRetentionMs() {
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
return typeof aisWindow.getDecodeHistoryRetentionMs === "function" ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3;
|
||||||
}
|
}
|
||||||
function pruneAisMessageHistory() {
|
function pruneAisMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
}
|
}
|
||||||
function scheduleAisUi(key, job) {
|
function scheduleAisUi(key, job) {
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
aisWindow.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
function scheduleAisHistoryRender() {
|
function scheduleAisHistoryRender() {
|
||||||
scheduleAisUi("ais-history", () => renderAisHistory());
|
scheduleAisUi("ais-history", () => {
|
||||||
|
renderAisHistory();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function scheduleAisBarUpdate() {
|
function scheduleAisBarUpdate() {
|
||||||
scheduleAisUi("ais-bar", () => updateAisBar());
|
scheduleAisUi("ais-bar", () => {
|
||||||
|
updateAisBar();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function formatAisMhz(freqHz) {
|
function formatAisMhz(freqHz) {
|
||||||
return `${(freqHz / 1e6).toFixed(3)} MHz`;
|
return `${(freqHz / 1e6).toFixed(3)} MHz`;
|
||||||
@@ -45,7 +53,7 @@ function currentAisChannelPlan() {
|
|||||||
}
|
}
|
||||||
function aisChannelInfo(channel) {
|
function aisChannelInfo(channel) {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
const ch = String(channel || "").trim().toUpperCase();
|
const ch = (channel ?? "").trim().toUpperCase();
|
||||||
if (ch === "B") {
|
if (ch === "B") {
|
||||||
return {
|
return {
|
||||||
label: "AIS-B",
|
label: "AIS-B",
|
||||||
@@ -63,10 +71,10 @@ function aisDisplayName(msg) {
|
|||||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
}
|
}
|
||||||
function aisDisplayNameHtml(msg) {
|
function aisDisplayNameHtml(msg) {
|
||||||
const label = escapeMapHtml(aisDisplayName(msg));
|
const label = escapeAisHtml(aisDisplayName(msg));
|
||||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
if (!url) return label;
|
if (!url) return label;
|
||||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
}
|
}
|
||||||
function aisTypeLabel(type) {
|
function aisTypeLabel(type) {
|
||||||
switch (Number(type)) {
|
switch (Number(type)) {
|
||||||
@@ -91,7 +99,7 @@ function aisTypeLabel(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function aisAgeText(tsMs) {
|
function aisAgeText(tsMs) {
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
const seconds = Math.round(deltaMs / 1e3);
|
const seconds = Math.round(deltaMs / 1e3);
|
||||||
if (seconds < 5) return "just now";
|
if (seconds < 5) return "just now";
|
||||||
@@ -103,9 +111,9 @@ function aisAgeText(tsMs) {
|
|||||||
}
|
}
|
||||||
function aisMotionText(msg) {
|
function aisMotionText(msg) {
|
||||||
const parts = [
|
const parts = [
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
||||||
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null
|
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
@@ -113,10 +121,10 @@ function aisRouteText(msg) {
|
|||||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
}
|
}
|
||||||
function aisDistanceText(msg) {
|
function aisDistanceText(msg) {
|
||||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon);
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1e3)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
@@ -174,7 +182,7 @@ function renderAisRow(msg) {
|
|||||||
msg.destination,
|
msg.destination,
|
||||||
aisTypeLabel(msg.message_type)
|
aisTypeLabel(msg.message_type)
|
||||||
].filter(Boolean).join(" ").toUpperCase();
|
].filter(Boolean).join(" ").toUpperCase();
|
||||||
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeMapHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
row.innerHTML = `<div class="ais-row-head"><span class="ais-time">${ts}</span><span class="ais-call">${nameHtml}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span></div><div class="ais-row-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span></div><div class="ais-row-detail">` + (motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + (distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (pos ? `<span>${pos}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span></div>`;
|
||||||
applyAisFilterToRow(row);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
@@ -186,17 +194,12 @@ function applyAisFilterToRow(row) {
|
|||||||
const message = row.dataset.filterText || "";
|
const message = row.dataset.filterText || "";
|
||||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
}
|
}
|
||||||
function applyAisFilterToAll() {
|
|
||||||
if (!aisMessagesEl) return;
|
|
||||||
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
|
||||||
rows.forEach((row) => applyAisFilterToRow(row));
|
|
||||||
}
|
|
||||||
function updateAisBar() {
|
function updateAisBar() {
|
||||||
if (!aisBarOverlay) return;
|
if (!aisBarOverlay) return;
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -211,28 +214,28 @@ function updateAisBar() {
|
|||||||
const channel = aisChannelInfo(msg.channel);
|
const channel = aisChannelInfo(msg.channel);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const details = [
|
const details = [
|
||||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
||||||
escapeMapHtml(channel.label),
|
escapeAisHtml(channel.label),
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
||||||
distance ? escapeMapHtml(distance) : null,
|
distance ? escapeAisHtml(distance) : null,
|
||||||
escapeMapHtml(aisAgeText(msg._tsMs))
|
escapeAisHtml(aisAgeText(msg._tsMs))
|
||||||
].filter(Boolean).join(" · ");
|
].filter(Boolean).join(" · ");
|
||||||
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
html += `<div class="aprs-bar-frame"><div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div></div>`;
|
||||||
}
|
}
|
||||||
aisBarOverlay.innerHTML = html;
|
aisBarOverlay.innerHTML = html;
|
||||||
aisBarOverlay.style.display = "flex";
|
aisBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateAisBar = updateAisBar;
|
aisWindow.updateAisBar = updateAisBar;
|
||||||
window.clearAisBar = function() {
|
aisWindow.clearAisBar = function() {
|
||||||
window.resetAisHistoryView();
|
aisWindow.resetAisHistoryView?.();
|
||||||
};
|
};
|
||||||
window.resetAisHistoryView = function() {
|
aisWindow.resetAisHistoryView = function() {
|
||||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
aisMessageHistory = [];
|
aisMessageHistory = [];
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
aisWindow.clearMapMarkersByType?.("ais");
|
||||||
};
|
};
|
||||||
function renderAisHistory() {
|
function renderAisHistory() {
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
@@ -241,8 +244,8 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
for (const message of aisMessageHistory) {
|
||||||
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
@@ -259,28 +262,17 @@ function addAisMessage(msg) {
|
|||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
window.aisMapAddVessel(msg);
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function normalizeServerAisMessage(msg) {
|
function normalizeServerAisMessage(msg) {
|
||||||
return {
|
return {
|
||||||
rig_id: msg.rig_id || null,
|
...msg,
|
||||||
channel: msg.channel,
|
rig_id: msg.rig_id || null
|
||||||
message_type: msg.message_type,
|
|
||||||
mmsi: msg.mmsi,
|
|
||||||
lat: msg.lat,
|
|
||||||
lon: msg.lon,
|
|
||||||
sog_knots: msg.sog_knots,
|
|
||||||
cog_deg: msg.cog_deg,
|
|
||||||
heading_deg: msg.heading_deg,
|
|
||||||
vessel_name: msg.vessel_name,
|
|
||||||
callsign: msg.callsign,
|
|
||||||
destination: msg.destination,
|
|
||||||
ts_ms: msg.ts_ms
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
window.onServerAisBatch = function(messages) {
|
aisWindow.onServerAisBatch = function(messages) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized = [];
|
||||||
@@ -293,8 +285,8 @@ window.onServerAisBatch = function(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit"
|
second: "2-digit"
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
window.aisMapAddVessel(next);
|
aisWindow.aisMapAddVessel(next);
|
||||||
}
|
}
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -304,22 +296,24 @@ window.onServerAisBatch = function(messages) {
|
|||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
};
|
};
|
||||||
window.restoreAisHistory = function(messages) {
|
aisWindow.restoreAisHistory = function(messages) {
|
||||||
window.onServerAisBatch(messages);
|
aisWindow.onServerAisBatch?.(messages);
|
||||||
};
|
};
|
||||||
window.pruneAisHistoryView = function() {
|
aisWindow.pruneAisHistoryView = function() {
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
};
|
};
|
||||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
|
||||||
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
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 {
|
try {
|
||||||
await postPath("/clear_ais_decode");
|
await aisWindow.postPath?.("/clear_ais_decode");
|
||||||
window.resetAisHistoryView();
|
aisWindow.resetAisHistoryView?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("AIS history clear failed", e);
|
console.error("AIS history clear failed", e);
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
if (aisFilterInput) {
|
if (aisFilterInput) {
|
||||||
aisFilterInput.addEventListener("input", () => {
|
aisFilterInput.addEventListener("input", () => {
|
||||||
@@ -327,9 +321,10 @@ if (aisFilterInput) {
|
|||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
window.onServerAis = function(msg) {
|
aisWindow.onServerAis = function(msg) {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
};
|
};
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
aisWindow._trxDrainPendingDecode?.("ais");
|
||||||
|
})();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ const pluginGroups = {
|
|||||||
};
|
};
|
||||||
const loaded = /* @__PURE__ */ new Set();
|
const loaded = /* @__PURE__ */ new Set();
|
||||||
const loading = /* @__PURE__ */ new Map();
|
const loading = /* @__PURE__ */ new Map();
|
||||||
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js"]);
|
const modulePlugins = /* @__PURE__ */ new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js"]);
|
||||||
function loadLegacyScript(path) {
|
function loadLegacyScript(path) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ await build({
|
|||||||
"plugin-loader": path.join(sourceDir, "plugin-loader.ts"),
|
"plugin-loader": path.join(sourceDir, "plugin-loader.ts"),
|
||||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||||
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
|
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
|
||||||
ais: path.join(sourceDir, "plugins", "ais.js"),
|
|
||||||
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
||||||
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
|
bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"),
|
||||||
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
|
"hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"),
|
||||||
@@ -51,6 +50,7 @@ await build({
|
|||||||
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
vdes: path.join(sourceDir, "plugins", "vdes.ts"),
|
||||||
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
wefax: path.join(sourceDir, "plugins", "wefax.ts"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.ts"),
|
||||||
|
ais: path.join(sourceDir, "plugins", "ais.ts"),
|
||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const pluginGroups: Readonly<Record<PluginGroup, readonly string[]>> = {
|
|||||||
|
|
||||||
const loaded = new Set<string>();
|
const loaded = new Set<string>();
|
||||||
const loading = new Map<string, Promise<void>>();
|
const loading = new Map<string, Promise<void>>();
|
||||||
const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js"]);
|
const modulePlugins = new Set(["/ft8.js", "/ft4.js", "/ft2.js", "/wspr.js", "/cw.js", "/vdes.js", "/wefax.js", "/background-decode.js", "/ais.js"]);
|
||||||
|
|
||||||
function loadLegacyScript(path: string): Promise<void> {
|
function loadLegacyScript(path: string): Promise<void> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
|
|||||||
+124
-95
@@ -2,10 +2,56 @@
|
|||||||
//
|
//
|
||||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
interface AisMessage {
|
||||||
|
rig_id?: string | null;
|
||||||
|
channel?: string | null;
|
||||||
|
message_type?: number | null;
|
||||||
|
mmsi?: number | null;
|
||||||
|
lat?: number | null;
|
||||||
|
lon?: number | null;
|
||||||
|
sog_knots?: number | null;
|
||||||
|
cog_deg?: number | null;
|
||||||
|
heading_deg?: number | null;
|
||||||
|
vessel_name?: string | null;
|
||||||
|
callsign?: string | null;
|
||||||
|
destination?: string | null;
|
||||||
|
ts_ms?: number | null;
|
||||||
|
_tsMs?: number;
|
||||||
|
_ts?: string;
|
||||||
|
}
|
||||||
|
interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
|
||||||
|
interface AisBridge {
|
||||||
|
getDecodeHistoryRetentionMs?: () => number;
|
||||||
|
trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
|
||||||
|
escapeMapHtml?: (input: string) => string;
|
||||||
|
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
|
||||||
|
serverLat?: number | null;
|
||||||
|
serverLon?: number | null;
|
||||||
|
haversineKm?: (lat1: number, lon1: number, lat2: number, lon2: number) => number;
|
||||||
|
aisMapAddVessel?: (message: AisMessage) => void;
|
||||||
|
clearMapMarkersByType?: (type: string) => void;
|
||||||
|
postPath?: (path: string) => Promise<unknown>;
|
||||||
|
trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise<boolean> };
|
||||||
|
updateAisBar?: () => void;
|
||||||
|
clearAisBar?: () => void;
|
||||||
|
resetAisHistoryView?: () => void;
|
||||||
|
onServerAisBatch?: (messages: AisMessage[]) => void;
|
||||||
|
restoreAisHistory?: (messages: AisMessage[]) => void;
|
||||||
|
pruneAisHistoryView?: () => void;
|
||||||
|
onServerAis?: (message: AisMessage) => void;
|
||||||
|
_trxDrainPendingDecode?: (decoder: string) => void;
|
||||||
|
}
|
||||||
|
const aisWindow = window as unknown as AisBridge;
|
||||||
|
const escapeAisHtml = (input: string): string => aisWindow.escapeMapHtml?.(input) ?? input
|
||||||
|
.replaceAll("&", "&").replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">").replaceAll('"', """);
|
||||||
|
|
||||||
// --- AIS Decoder Plugin (server-side decode) ---
|
// --- AIS Decoder Plugin (server-side decode) ---
|
||||||
const aisStatus = document.getElementById("ais-status");
|
const aisStatus = document.getElementById("ais-status");
|
||||||
const aisMessagesEl = document.getElementById("ais-messages");
|
const aisMessagesEl = document.getElementById("ais-messages");
|
||||||
const aisFilterInput = document.getElementById("ais-filter");
|
const aisFilterInput = document.getElementById("ais-filter") as HTMLInputElement | null;
|
||||||
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
const aisBarOverlay = document.getElementById("ais-bar-overlay");
|
||||||
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
const aisChannelSummaryEl = document.getElementById("ais-channel-summary");
|
||||||
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
const aisVesselCountEl = document.getElementById("ais-vessel-count");
|
||||||
@@ -14,41 +60,41 @@ const AIS_BAR_WINDOW_MS = 15 * 60 * 1000;
|
|||||||
const AIS_DEFAULT_A_HZ = 161_975_000;
|
const AIS_DEFAULT_A_HZ = 161_975_000;
|
||||||
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
const AIS_CHANNEL_SPACING_HZ = 50_000;
|
||||||
let aisFilterText = "";
|
let aisFilterText = "";
|
||||||
let aisMessageHistory = [];
|
let aisMessageHistory: AisMessage[] = [];
|
||||||
|
|
||||||
function currentAisHistoryRetentionMs() {
|
function currentAisHistoryRetentionMs(): number {
|
||||||
return typeof window.getDecodeHistoryRetentionMs === "function"
|
return typeof aisWindow.getDecodeHistoryRetentionMs === "function"
|
||||||
? window.getDecodeHistoryRetentionMs()
|
? aisWindow.getDecodeHistoryRetentionMs()
|
||||||
: 24 * 60 * 60 * 1000;
|
: 24 * 60 * 60 * 1000;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisMessageHistory() {
|
function pruneAisMessageHistory() {
|
||||||
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
const cutoffMs = Date.now() - currentAisHistoryRetentionMs();
|
||||||
aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs);
|
aisMessageHistory = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisUi(key, job) {
|
function scheduleAisUi(key: string, job: () => void): void {
|
||||||
if (typeof window.trxScheduleUiFrameJob === "function") {
|
if (typeof aisWindow.trxScheduleUiFrameJob === "function") {
|
||||||
window.trxScheduleUiFrameJob(key, job);
|
aisWindow.trxScheduleUiFrameJob(key, job);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
job();
|
job();
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisHistoryRender() {
|
function scheduleAisHistoryRender() {
|
||||||
scheduleAisUi("ais-history", () => renderAisHistory());
|
scheduleAisUi("ais-history", () => { renderAisHistory(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleAisBarUpdate() {
|
function scheduleAisBarUpdate() {
|
||||||
scheduleAisUi("ais-bar", () => updateAisBar());
|
scheduleAisUi("ais-bar", () => { updateAisBar(); });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatAisMhz(freqHz) {
|
function formatAisMhz(freqHz: number): string {
|
||||||
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
return `${(freqHz / 1_000_000).toFixed(3)} MHz`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentAisChannelPlan() {
|
function currentAisChannelPlan(): { aHz: number; bHz: number } {
|
||||||
const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, "");
|
const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.value || "").replace(/[^\d]/g, "");
|
||||||
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
const aHz = raw ? Number(raw) : AIS_DEFAULT_A_HZ;
|
||||||
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
const safeAHz = Number.isFinite(aHz) && aHz > 0 ? aHz : AIS_DEFAULT_A_HZ;
|
||||||
return {
|
return {
|
||||||
@@ -57,9 +103,9 @@ function currentAisChannelPlan() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisChannelInfo(channel) {
|
function aisChannelInfo(channel: string | null | undefined): AisChannelInfo {
|
||||||
const plan = currentAisChannelPlan();
|
const plan = currentAisChannelPlan();
|
||||||
const ch = String(channel || "").trim().toUpperCase();
|
const ch = (channel ?? "").trim().toUpperCase();
|
||||||
if (ch === "B") {
|
if (ch === "B") {
|
||||||
return {
|
return {
|
||||||
label: "AIS-B",
|
label: "AIS-B",
|
||||||
@@ -74,18 +120,18 @@ function aisChannelInfo(channel) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDisplayName(msg) {
|
function aisDisplayName(msg: AisMessage): string {
|
||||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDisplayNameHtml(msg) {
|
function aisDisplayNameHtml(msg: AisMessage): string {
|
||||||
const label = escapeMapHtml(aisDisplayName(msg));
|
const label = escapeAisHtml(aisDisplayName(msg));
|
||||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
|
||||||
if (!url) return label;
|
if (!url) return label;
|
||||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
return `<a class="title-link" href="${escapeAisHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisTypeLabel(type) {
|
function aisTypeLabel(type: number | null | undefined): string {
|
||||||
switch (Number(type)) {
|
switch (Number(type)) {
|
||||||
case 1:
|
case 1:
|
||||||
case 2:
|
case 2:
|
||||||
@@ -108,8 +154,8 @@ function aisTypeLabel(type) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisAgeText(tsMs) {
|
function aisAgeText(tsMs: number | undefined): string {
|
||||||
if (!Number.isFinite(tsMs)) return "just now";
|
if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now";
|
||||||
const deltaMs = Math.max(0, Date.now() - tsMs);
|
const deltaMs = Math.max(0, Date.now() - tsMs);
|
||||||
const seconds = Math.round(deltaMs / 1000);
|
const seconds = Math.round(deltaMs / 1000);
|
||||||
if (seconds < 5) return "just now";
|
if (seconds < 5) return "just now";
|
||||||
@@ -120,31 +166,31 @@ function aisAgeText(tsMs) {
|
|||||||
return `${hours}h ago`;
|
return `${hours}h ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisMotionText(msg) {
|
function aisMotionText(msg: AisMessage): string {
|
||||||
const parts = [
|
const parts = [
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null,
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}° COG` : null,
|
||||||
msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null,
|
msg.heading_deg != null ? `${msg.heading_deg.toFixed(0)}° HDG` : null,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisRouteText(msg) {
|
function aisRouteText(msg: AisMessage): string {
|
||||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisDistanceText(msg) {
|
function aisDistanceText(msg: AisMessage): string {
|
||||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) {
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
const distKm = aisWindow.haversineKm(aisWindow.serverLat, aisWindow.serverLon, msg.lat, msg.lon);
|
||||||
if (!Number.isFinite(distKm)) return "";
|
if (!Number.isFinite(distKm)) return "";
|
||||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||||
return `${distKm.toFixed(1)} km from TRX`;
|
return `${distKm.toFixed(1)} km from TRX`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function aisLatestByVessel(messages) {
|
function aisLatestByVessel(messages: AisMessage[]): AisMessage[] {
|
||||||
const byMmsi = new Map();
|
const byMmsi = new Map<string, AisMessage>();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
const key = Number.isFinite(msg.mmsi) ? String(msg.mmsi) : `${msg.channel || "?"}:${msg._tsMs || 0}`;
|
||||||
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
if (!byMmsi.has(key)) byMmsi.set(key, msg);
|
||||||
@@ -175,7 +221,7 @@ function updateAisSummary() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAisRow(msg) {
|
function renderAisRow(msg: AisMessage): HTMLElement {
|
||||||
const row = document.createElement("div");
|
const row = document.createElement("div");
|
||||||
row.className = "ais-message";
|
row.className = "ais-message";
|
||||||
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
const ts = msg._ts || new Date().toLocaleTimeString([], {
|
||||||
@@ -209,25 +255,25 @@ function renderAisRow(msg) {
|
|||||||
`<div class="ais-row-head">` +
|
`<div class="ais-row-head">` +
|
||||||
`<span class="ais-time">${ts}</span>` +
|
`<span class="ais-time">${ts}</span>` +
|
||||||
`<span class="ais-call">${nameHtml}</span>` +
|
`<span class="ais-call">${nameHtml}</span>` +
|
||||||
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
`<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
|
||||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
`<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-meta">` +
|
`<div class="ais-row-meta">` +
|
||||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
|
||||||
(route ? `<span class="ais-meta-text">${escapeMapHtml(route)}</span>` : "") +
|
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") +
|
||||||
`<span class="ais-meta-text">${escapeMapHtml(channel.freqText)}</span>` +
|
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-detail">` +
|
`<div class="ais-row-detail">` +
|
||||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||||
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") +
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
(pos ? `<span>${pos}</span>` : "") +
|
||||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
applyAisFilterToRow(row);
|
applyAisFilterToRow(row);
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAisFilterToRow(row) {
|
function applyAisFilterToRow(row: HTMLElement): void {
|
||||||
if (!aisFilterText) {
|
if (!aisFilterText) {
|
||||||
row.style.display = "";
|
row.style.display = "";
|
||||||
return;
|
return;
|
||||||
@@ -236,19 +282,13 @@ function applyAisFilterToRow(row) {
|
|||||||
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
row.style.display = message.includes(aisFilterText) ? "" : "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyAisFilterToAll() {
|
|
||||||
if (!aisMessagesEl) return;
|
|
||||||
const rows = aisMessagesEl.querySelectorAll(".ais-message");
|
|
||||||
rows.forEach((row) => applyAisFilterToRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAisBar() {
|
function updateAisBar() {
|
||||||
if (!aisBarOverlay) return;
|
if (!aisBarOverlay) return;
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
|
|
||||||
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
|
const isAis = ((document.getElementById("mode") as HTMLSelectElement | null)?.value || "").toUpperCase() === "AIS";
|
||||||
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
|
||||||
const recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
|
const recent = aisMessageHistory.filter((msg) => (msg._tsMs ?? 0) >= cutoffMs);
|
||||||
const messages = aisLatestByVessel(recent).slice(0, 8);
|
const messages = aisLatestByVessel(recent).slice(0, 8);
|
||||||
if (!isAis || messages.length === 0) {
|
if (!isAis || messages.length === 0) {
|
||||||
aisBarOverlay.style.display = "none";
|
aisBarOverlay.style.display = "none";
|
||||||
@@ -266,12 +306,12 @@ function updateAisBar() {
|
|||||||
const channel = aisChannelInfo(msg.channel);
|
const channel = aisChannelInfo(msg.channel);
|
||||||
const distance = aisDistanceText(msg);
|
const distance = aisDistanceText(msg);
|
||||||
const details = [
|
const details = [
|
||||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
`MMSI ${escapeAisHtml(String(msg.mmsi))}`,
|
||||||
escapeMapHtml(channel.label),
|
escapeAisHtml(channel.label),
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${msg.sog_knots.toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
msg.cog_deg != null ? `${msg.cog_deg.toFixed(1)}°` : null,
|
||||||
distance ? escapeMapHtml(distance) : null,
|
distance ? escapeAisHtml(distance) : null,
|
||||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
escapeAisHtml(aisAgeText(msg._tsMs)),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(" · ");
|
.join(" · ");
|
||||||
@@ -282,17 +322,17 @@ function updateAisBar() {
|
|||||||
aisBarOverlay.innerHTML = html;
|
aisBarOverlay.innerHTML = html;
|
||||||
aisBarOverlay.style.display = "flex";
|
aisBarOverlay.style.display = "flex";
|
||||||
}
|
}
|
||||||
window.updateAisBar = updateAisBar;
|
aisWindow.updateAisBar = updateAisBar;
|
||||||
window.clearAisBar = function() {
|
aisWindow.clearAisBar = function() {
|
||||||
window.resetAisHistoryView();
|
aisWindow.resetAisHistoryView?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.resetAisHistoryView = function() {
|
aisWindow.resetAisHistoryView = function() {
|
||||||
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
if (aisMessagesEl) aisMessagesEl.innerHTML = "";
|
||||||
aisMessageHistory = [];
|
aisMessageHistory = [];
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais");
|
aisWindow.clearMapMarkersByType?.("ais");
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderAisHistory() {
|
function renderAisHistory() {
|
||||||
@@ -302,14 +342,14 @@ function renderAisHistory() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const fragment = document.createDocumentFragment();
|
const fragment = document.createDocumentFragment();
|
||||||
for (let i = 0; i < aisMessageHistory.length; i += 1) {
|
for (const message of aisMessageHistory) {
|
||||||
fragment.appendChild(renderAisRow(aisMessageHistory[i]));
|
fragment.appendChild(renderAisRow(message));
|
||||||
}
|
}
|
||||||
aisMessagesEl.replaceChildren(fragment);
|
aisMessagesEl.replaceChildren(fragment);
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
function addAisMessage(msg) {
|
function addAisMessage(msg: AisMessage): void {
|
||||||
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now();
|
||||||
msg._tsMs = tsMs;
|
msg._tsMs = tsMs;
|
||||||
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
msg._ts = new Date(tsMs).toLocaleTimeString([], {
|
||||||
@@ -323,33 +363,22 @@ function addAisMessage(msg) {
|
|||||||
scheduleAisBarUpdate();
|
scheduleAisBarUpdate();
|
||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
|
|
||||||
if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) {
|
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
window.aisMapAddVessel(msg);
|
aisWindow.aisMapAddVessel(msg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeServerAisMessage(msg) {
|
function normalizeServerAisMessage(msg: AisMessage): AisMessage {
|
||||||
return {
|
return {
|
||||||
|
...msg,
|
||||||
rig_id: msg.rig_id || null,
|
rig_id: msg.rig_id || null,
|
||||||
channel: msg.channel,
|
|
||||||
message_type: msg.message_type,
|
|
||||||
mmsi: msg.mmsi,
|
|
||||||
lat: msg.lat,
|
|
||||||
lon: msg.lon,
|
|
||||||
sog_knots: msg.sog_knots,
|
|
||||||
cog_deg: msg.cog_deg,
|
|
||||||
heading_deg: msg.heading_deg,
|
|
||||||
vessel_name: msg.vessel_name,
|
|
||||||
callsign: msg.callsign,
|
|
||||||
destination: msg.destination,
|
|
||||||
ts_ms: msg.ts_ms,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onServerAisBatch = function(messages) {
|
aisWindow.onServerAisBatch = function(messages: AisMessage[]) {
|
||||||
if (!Array.isArray(messages) || messages.length === 0) return;
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
const normalized = [];
|
const normalized: AisMessage[] = [];
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const next = normalizeServerAisMessage(msg);
|
const next = normalizeServerAisMessage(msg);
|
||||||
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
||||||
@@ -359,8 +388,8 @@ window.onServerAisBatch = function(messages) {
|
|||||||
minute: "2-digit",
|
minute: "2-digit",
|
||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
if (next.lat != null && next.lon != null && window.aisMapAddVessel) {
|
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
||||||
window.aisMapAddVessel(next);
|
aisWindow.aisMapAddVessel(next);
|
||||||
}
|
}
|
||||||
normalized.push(next);
|
normalized.push(next);
|
||||||
}
|
}
|
||||||
@@ -371,25 +400,25 @@ window.onServerAisBatch = function(messages) {
|
|||||||
scheduleAisHistoryRender();
|
scheduleAisHistoryRender();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.restoreAisHistory = function(messages) {
|
aisWindow.restoreAisHistory = function(messages: AisMessage[]) {
|
||||||
window.onServerAisBatch(messages);
|
aisWindow.onServerAisBatch?.(messages);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.pruneAisHistoryView = function() {
|
aisWindow.pruneAisHistoryView = function() {
|
||||||
pruneAisMessageHistory();
|
pruneAisMessageHistory();
|
||||||
updateAisBar();
|
updateAisBar();
|
||||||
renderAisHistory();
|
renderAisHistory();
|
||||||
};
|
};
|
||||||
|
|
||||||
document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => {
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => { void (async () => {
|
||||||
if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
||||||
try {
|
try {
|
||||||
await postPath("/clear_ais_decode");
|
await aisWindow.postPath?.("/clear_ais_decode");
|
||||||
window.resetAisHistoryView();
|
aisWindow.resetAisHistoryView?.();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("AIS history clear failed", e);
|
console.error("AIS history clear failed", e);
|
||||||
}
|
}
|
||||||
});
|
})(); });
|
||||||
|
|
||||||
if (aisFilterInput) {
|
if (aisFilterInput) {
|
||||||
aisFilterInput.addEventListener("input", () => {
|
aisFilterInput.addEventListener("input", () => {
|
||||||
@@ -398,10 +427,10 @@ if (aisFilterInput) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onServerAis = function(msg) {
|
aisWindow.onServerAis = function(msg: AisMessage) {
|
||||||
if (aisStatus) aisStatus.textContent = "Receiving";
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
||||||
addAisMessage(normalizeServerAisMessage(msg));
|
addAisMessage(normalizeServerAisMessage(msg));
|
||||||
};
|
};
|
||||||
|
|
||||||
updateAisSummary();
|
updateAisSummary();
|
||||||
if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais");
|
aisWindow._trxDrainPendingDecode?.("ais");
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import test from "node:test";
|
||||||
|
import vm from "node:vm";
|
||||||
|
|
||||||
|
test("AIS entry forwards positioned vessels with normalized metadata", async () => {
|
||||||
|
const forwarded = [];
|
||||||
|
const window = {
|
||||||
|
trxUi: { confirm: async () => true },
|
||||||
|
aisMapAddVessel: (message) => { forwarded.push(message); },
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
window,
|
||||||
|
document: { getElementById: () => null },
|
||||||
|
Date,
|
||||||
|
Number,
|
||||||
|
String,
|
||||||
|
Array,
|
||||||
|
Map,
|
||||||
|
console,
|
||||||
|
});
|
||||||
|
const source = await readFile(new URL("../../assets/web/generated/ais.js", import.meta.url), "utf8");
|
||||||
|
new vm.Script(source).runInContext(context);
|
||||||
|
|
||||||
|
window.onServerAis({ mmsi: 261000001, lat: 54.5, lon: 18.5, channel: "A", ts_ms: Date.now() });
|
||||||
|
assert.equal(forwarded.length, 1);
|
||||||
|
assert.equal(forwarded[0].mmsi, 261000001);
|
||||||
|
assert.equal(forwarded[0].rig_id, null);
|
||||||
|
assert.equal(typeof forwarded[0]._tsMs, "number");
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user