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 0a8c4212..b4659723 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,335 +1,330 @@ "use strict"; -const aisStatus = document.getElementById("ais-status"); -const aisMessagesEl = document.getElementById("ais-messages"); -const aisFilterInput = document.getElementById("ais-filter"); -const aisBarOverlay = document.getElementById("ais-bar-overlay"); -const aisChannelSummaryEl = document.getElementById("ais-channel-summary"); -const aisVesselCountEl = document.getElementById("ais-vessel-count"); -const aisLatestSeenEl = document.getElementById("ais-latest-seen"); -const AIS_BAR_WINDOW_MS = 15 * 60 * 1e3; -const AIS_DEFAULT_A_HZ = 161975e3; -const AIS_CHANNEL_SPACING_HZ = 5e4; -let aisFilterText = ""; -let aisMessageHistory = []; -function currentAisHistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" ? window.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1e3; -} -function pruneAisMessageHistory() { - const cutoffMs = Date.now() - currentAisHistoryRetentionMs(); - aisMessageHistory = aisMessageHistory.filter((msg) => Number(msg?._tsMs) >= cutoffMs); -} -function scheduleAisUi(key, job) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); - return; +(() => { + // 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; } - 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 = String(channel || "").trim().toUpperCase(); - if (ch === "B") { + 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 { - label: "AIS-B", - badgeClass: "ais-badge ais-badge-channel-b", - freqText: formatAisMhz(plan.bHz) + aHz: safeAHz, + bHz: safeAHz + AIS_CHANNEL_SPACING_HZ }; } - 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 = escapeMapHtml(aisDisplayName(msg)); - const url = window.buildAisVesselUrl ? window.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 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 aisAgeText(tsMs) { - if (!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 ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, - msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null, - msg.heading_deg != null ? `${Number(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 (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) { - return ""; + function aisDisplayName(msg) { + return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`; } - const distKm = haversineKm(serverLat, 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); + function aisDisplayNameHtml(msg) { + const label = escapeAisHtml(aisDisplayName(msg)); + const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null; + if (!url) return label; + return `${label}`; } - 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 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 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}${escapeMapHtml(channel.label)}${escapeMapHtml(aisTypeLabel(msg.message_type))}
MMSI ${escapeMapHtml(String(msg.mmsi))}` + (route ? `${escapeMapHtml(route)}` : "") + `${escapeMapHtml(channel.freqText)}
` + (motion ? `${escapeMapHtml(motion)}` : `No motion data`) + (distance ? `${escapeMapHtml(distance)}` : "") + (pos ? `${pos}` : "") + `${escapeMapHtml(aisAgeText(msg._tsMs))}
`; - applyAisFilterToRow(row); - return row; -} -function applyAisFilterToRow(row) { - if (!aisFilterText) { - row.style.display = ""; - return; + 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`; } - const message = row.dataset.filterText || ""; - 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() { - 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 >= cutoffMs); - const messages = aisLatestByVessel(recent).slice(0, 8); - if (!isAis || messages.length === 0) { - aisBarOverlay.style.display = "none"; - aisBarOverlay.innerHTML = ""; - return; + 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(" · "); } - 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 ${escapeMapHtml(String(msg.mmsi))}`, - escapeMapHtml(channel.label), - msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, - msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null, - distance ? escapeMapHtml(distance) : null, - escapeMapHtml(aisAgeText(msg._tsMs)) - ].filter(Boolean).join(" · "); - html += `
${ts}${pin}${name}: ${details}
`; + function aisRouteText(msg) { + return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); } - aisBarOverlay.innerHTML = html; - aisBarOverlay.style.display = "flex"; -} -window.updateAisBar = updateAisBar; -window.clearAisBar = function() { - window.resetAisHistoryView(); -}; -window.resetAisHistoryView = function() { - if (aisMessagesEl) aisMessagesEl.innerHTML = ""; - aisMessageHistory = []; - updateAisBar(); - renderAisHistory(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais"); -}; -function renderAisHistory() { - pruneAisMessageHistory(); - if (!aisMessagesEl) { - updateAisSummary(); - return; + 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`; } - const fragment = document.createDocumentFragment(); - for (let i = 0; i < aisMessageHistory.length; i += 1) { - fragment.appendChild(renderAisRow(aisMessageHistory[i])); + 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()); } - 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 && window.aisMapAddVessel) { - window.aisMapAddVessel(msg); + 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 normalizeServerAisMessage(msg) { - return { - 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) { - 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([], { + 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" }); - if (next.lat != null && next.lon != null && window.aisMapAddVessel) { - window.aisMapAddVessel(next); + 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; } - normalized.push(next); + const message = row.dataset.filterText || ""; + row.style.display = message.includes(aisFilterText) ? "" : "none"; } - normalized.reverse(); - aisMessageHistory = normalized.concat(aisMessageHistory); - pruneAisMessageHistory(); - scheduleAisBarUpdate(); - scheduleAisHistoryRender(); -}; -window.restoreAisHistory = function(messages) { - window.onServerAisBatch(messages); -}; -window.pruneAisHistoryView = function() { - pruneAisMessageHistory(); - updateAisBar(); - renderAisHistory(); -}; -document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; - try { - await postPath("/clear_ais_decode"); - window.resetAisHistoryView(); - } catch (e) { - console.error("AIS history clear failed", e); + 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"; } -}); -if (aisFilterInput) { - aisFilterInput.addEventListener("input", () => { - aisFilterText = aisFilterInput.value.trim().toUpperCase(); + aisWindow.updateAisBar = updateAisBar; + aisWindow.clearAisBar = function() { + aisWindow.resetAisHistoryView?.(); + }; + aisWindow.resetAisHistoryView = function() { + 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(); + if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { + aisWindow.aisMapAddVessel(msg); + } + } + function normalizeServerAisMessage(msg) { + return { + ...msg, + rig_id: msg.rig_id || null + }; + } + aisWindow.onServerAisBatch = function(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(); + }; + aisWindow.restoreAisHistory = function(messages) { + aisWindow.onServerAisBatch?.(messages); + }; + aisWindow.pruneAisHistoryView = function() { + 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"); + aisWindow.resetAisHistoryView?.(); + } catch (e) { + console.error("AIS history clear failed", e); + } + })(); }); -} -window.onServerAis = function(msg) { - if (aisStatus) aisStatus.textContent = "Receiving"; - addAisMessage(normalizeServerAisMessage(msg)); -}; -updateAisSummary(); -if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais"); + if (aisFilterInput) { + aisFilterInput.addEventListener("input", () => { + aisFilterText = aisFilterInput.value.trim().toUpperCase(); + renderAisHistory(); + }); + } + aisWindow.onServerAis = function(msg) { + if (aisStatus) aisStatus.textContent = "Receiving"; + addAisMessage(normalizeServerAisMessage(msg)); + }; + updateAisSummary(); + aisWindow._trxDrainPendingDecode?.("ais"); +})(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js index 36edf444..3f802303 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/plugin-loader.js @@ -10,7 +10,7 @@ const pluginGroups = { }; const loaded = /* @__PURE__ */ new Set(); 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) { return new Promise((resolve, reject) => { const script = document.createElement("script"); 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 b1753331..5a505e65 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 @@ -22,7 +22,6 @@ await build({ "plugin-loader": path.join(sourceDir, "plugin-loader.ts"), screenshot: path.join(sourceDir, "screenshot.ts"), "webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"), - ais: path.join(sourceDir, "plugins", "ais.js"), aprs: path.join(sourceDir, "plugins", "aprs.js"), bookmarks: path.join(sourceDir, "plugins", "bookmarks.js"), "hf-aprs": path.join(sourceDir, "plugins", "hf-aprs.js"), @@ -51,6 +50,7 @@ await build({ vdes: path.join(sourceDir, "plugins", "vdes.ts"), wefax: path.join(sourceDir, "plugins", "wefax.ts"), "background-decode": path.join(sourceDir, "plugins", "background-decode.ts"), + ais: path.join(sourceDir, "plugins", "ais.ts"), }, outdir: outputDir, bundle: true, diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts index 7edc91ce..217606b9 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugin-loader.ts @@ -16,7 +16,7 @@ const pluginGroups: Readonly> = { const loaded = new Set(); const loading = new Map>(); -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 { return new Promise((resolve, reject) => { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts similarity index 58% rename from src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js rename to src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index 3f6dae8b..5b5ba892 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -2,10 +2,56 @@ // // 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; + trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; + 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) --- const aisStatus = document.getElementById("ais-status"); 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 aisChannelSummaryEl = document.getElementById("ais-channel-summary"); 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_CHANNEL_SPACING_HZ = 50_000; let aisFilterText = ""; -let aisMessageHistory = []; +let aisMessageHistory: AisMessage[] = []; -function currentAisHistoryRetentionMs() { - return typeof window.getDecodeHistoryRetentionMs === "function" - ? window.getDecodeHistoryRetentionMs() +function currentAisHistoryRetentionMs(): number { + return typeof aisWindow.getDecodeHistoryRetentionMs === "function" + ? aisWindow.getDecodeHistoryRetentionMs() : 24 * 60 * 60 * 1000; } function pruneAisMessageHistory() { 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) { - if (typeof window.trxScheduleUiFrameJob === "function") { - window.trxScheduleUiFrameJob(key, job); +function scheduleAisUi(key: string, job: () => void): void { + if (typeof aisWindow.trxScheduleUiFrameJob === "function") { + aisWindow.trxScheduleUiFrameJob(key, job); return; } job(); } function scheduleAisHistoryRender() { - scheduleAisUi("ais-history", () => renderAisHistory()); + scheduleAisUi("ais-history", () => { renderAisHistory(); }); } 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`; } -function currentAisChannelPlan() { - const raw = (document.getElementById("freq")?.value || "").replace(/[^\d]/g, ""); +function currentAisChannelPlan(): { aHz: number; bHz: number } { + const raw = ((document.getElementById("freq") as HTMLInputElement | null)?.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 { @@ -57,9 +103,9 @@ function currentAisChannelPlan() { }; } -function aisChannelInfo(channel) { +function aisChannelInfo(channel: string | null | undefined): AisChannelInfo { const plan = currentAisChannelPlan(); - const ch = String(channel || "").trim().toUpperCase(); + const ch = (channel ?? "").trim().toUpperCase(); if (ch === "B") { return { 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}`; } -function aisDisplayNameHtml(msg) { - const label = escapeMapHtml(aisDisplayName(msg)); - const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null; +function aisDisplayNameHtml(msg: AisMessage): string { + const label = escapeAisHtml(aisDisplayName(msg)); + const url = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null; if (!url) return label; - return `${label}`; + return `${label}`; } -function aisTypeLabel(type) { +function aisTypeLabel(type: number | null | undefined): string { switch (Number(type)) { case 1: case 2: @@ -108,8 +154,8 @@ function aisTypeLabel(type) { } } -function aisAgeText(tsMs) { - if (!Number.isFinite(tsMs)) return "just now"; +function aisAgeText(tsMs: number | undefined): string { + if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "just now"; const deltaMs = Math.max(0, Date.now() - tsMs); const seconds = Math.round(deltaMs / 1000); if (seconds < 5) return "just now"; @@ -120,31 +166,31 @@ function aisAgeText(tsMs) { return `${hours}h ago`; } -function aisMotionText(msg) { +function aisMotionText(msg: AisMessage): string { const parts = [ - msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, - msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}° COG` : null, - msg.heading_deg != null ? `${Number(msg.heading_deg).toFixed(0)}° HDG` : null, + 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) { +function aisRouteText(msg: AisMessage): string { return [msg.callsign, msg.destination].filter(Boolean).join(" -> "); } -function aisDistanceText(msg) { - if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) { +function aisDistanceText(msg: AisMessage): string { + if (aisWindow.serverLat == null || aisWindow.serverLon == null || msg.lat == null || msg.lon == null || !aisWindow.haversineKm) { 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 (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`; return `${distKm.toFixed(1)} km from TRX`; } -function aisLatestByVessel(messages) { - const byMmsi = new Map(); +function aisLatestByVessel(messages: AisMessage[]): AisMessage[] { + const byMmsi = 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); @@ -175,7 +221,7 @@ function updateAisSummary() { } } -function renderAisRow(msg) { +function renderAisRow(msg: AisMessage): HTMLElement { const row = document.createElement("div"); row.className = "ais-message"; const ts = msg._ts || new Date().toLocaleTimeString([], { @@ -209,25 +255,25 @@ function renderAisRow(msg) { `
` + `${ts}` + `${nameHtml}` + - `${escapeMapHtml(channel.label)}` + - `${escapeMapHtml(aisTypeLabel(msg.message_type))}` + + `${escapeAisHtml(channel.label)}` + + `${escapeAisHtml(aisTypeLabel(msg.message_type))}` + `
` + `
` + - `MMSI ${escapeMapHtml(String(msg.mmsi))}` + - (route ? `${escapeMapHtml(route)}` : "") + - `${escapeMapHtml(channel.freqText)}` + + `MMSI ${escapeAisHtml(String(msg.mmsi))}` + + (route ? `${escapeAisHtml(route)}` : "") + + `${escapeAisHtml(channel.freqText)}` + `
` + `
` + - (motion ? `${escapeMapHtml(motion)}` : `No motion data`) + - (distance ? `${escapeMapHtml(distance)}` : "") + + (motion ? `${escapeAisHtml(motion)}` : `No motion data`) + + (distance ? `${escapeAisHtml(distance)}` : "") + (pos ? `${pos}` : "") + - `${escapeMapHtml(aisAgeText(msg._tsMs))}` + + `${escapeAisHtml(aisAgeText(msg._tsMs))}` + `
`; applyAisFilterToRow(row); return row; } -function applyAisFilterToRow(row) { +function applyAisFilterToRow(row: HTMLElement): void { if (!aisFilterText) { row.style.display = ""; return; @@ -236,19 +282,13 @@ function applyAisFilterToRow(row) { 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() { if (!aisBarOverlay) return; 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 recent = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs); + 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"; @@ -266,12 +306,12 @@ function updateAisBar() { const channel = aisChannelInfo(msg.channel); const distance = aisDistanceText(msg); const details = [ - `MMSI ${escapeMapHtml(String(msg.mmsi))}`, - escapeMapHtml(channel.label), - msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null, - msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null, - distance ? escapeMapHtml(distance) : null, - escapeMapHtml(aisAgeText(msg._tsMs)), + `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(" · "); @@ -282,17 +322,17 @@ function updateAisBar() { aisBarOverlay.innerHTML = html; aisBarOverlay.style.display = "flex"; } -window.updateAisBar = updateAisBar; -window.clearAisBar = function() { - window.resetAisHistoryView(); +aisWindow.updateAisBar = updateAisBar; +aisWindow.clearAisBar = function() { + aisWindow.resetAisHistoryView?.(); }; -window.resetAisHistoryView = function() { +aisWindow.resetAisHistoryView = function() { if (aisMessagesEl) aisMessagesEl.innerHTML = ""; aisMessageHistory = []; updateAisBar(); renderAisHistory(); - if (window.clearMapMarkersByType) window.clearMapMarkersByType("ais"); + aisWindow.clearMapMarkersByType?.("ais"); }; function renderAisHistory() { @@ -302,14 +342,14 @@ function renderAisHistory() { return; } const fragment = document.createDocumentFragment(); - for (let i = 0; i < aisMessageHistory.length; i += 1) { - fragment.appendChild(renderAisRow(aisMessageHistory[i])); + for (const message of aisMessageHistory) { + fragment.appendChild(renderAisRow(message)); } aisMessagesEl.replaceChildren(fragment); updateAisSummary(); } -function addAisMessage(msg) { +function addAisMessage(msg: AisMessage): void { const tsMs = Number.isFinite(msg.ts_ms) ? Number(msg.ts_ms) : Date.now(); msg._tsMs = tsMs; msg._ts = new Date(tsMs).toLocaleTimeString([], { @@ -323,33 +363,22 @@ function addAisMessage(msg) { scheduleAisBarUpdate(); scheduleAisHistoryRender(); - if (msg.lat != null && msg.lon != null && window.aisMapAddVessel) { - window.aisMapAddVessel(msg); + if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) { + aisWindow.aisMapAddVessel(msg); } } -function normalizeServerAisMessage(msg) { +function normalizeServerAisMessage(msg: AisMessage): AisMessage { return { + ...msg, 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 (aisStatus) aisStatus.textContent = "Receiving"; - const normalized = []; + const normalized: AisMessage[] = []; for (const msg of messages) { const next = normalizeServerAisMessage(msg); const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now(); @@ -359,8 +388,8 @@ window.onServerAisBatch = function(messages) { minute: "2-digit", second: "2-digit", }); - if (next.lat != null && next.lon != null && window.aisMapAddVessel) { - window.aisMapAddVessel(next); + if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) { + aisWindow.aisMapAddVessel(next); } normalized.push(next); } @@ -371,25 +400,25 @@ window.onServerAisBatch = function(messages) { scheduleAisHistoryRender(); }; -window.restoreAisHistory = function(messages) { - window.onServerAisBatch(messages); +aisWindow.restoreAisHistory = function(messages: AisMessage[]) { + aisWindow.onServerAisBatch?.(messages); }; -window.pruneAisHistoryView = function() { +aisWindow.pruneAisHistoryView = function() { pruneAisMessageHistory(); updateAisBar(); renderAisHistory(); }; -document.getElementById("settings-clear-ais-history")?.addEventListener("click", async () => { - if (!await window.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return; +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 postPath("/clear_ais_decode"); - window.resetAisHistoryView(); + await aisWindow.postPath?.("/clear_ais_decode"); + aisWindow.resetAisHistoryView?.(); } catch (e) { console.error("AIS history clear failed", e); } -}); +})(); }); if (aisFilterInput) { aisFilterInput.addEventListener("input", () => { @@ -398,10 +427,10 @@ if (aisFilterInput) { }); } -window.onServerAis = function(msg) { +aisWindow.onServerAis = function(msg: AisMessage) { if (aisStatus) aisStatus.textContent = "Receiving"; addAisMessage(normalizeServerAisMessage(msg)); }; updateAisSummary(); -if (window._trxDrainPendingDecode) window._trxDrainPendingDecode("ais"); +aisWindow._trxDrainPendingDecode?.("ais"); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs new file mode 100644 index 00000000..3a0e2248 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/ais.test.mjs @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// 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"); +});