CI / lint (pull_request) Failing after 2s
CI / test (pull_request) Failing after 1s
CI / frontend (pull_request) Failing after 41s
CI / reuse (pull_request) Failing after 2s
CI / lint (push) Failing after 2s
CI / test (push) Failing after 2s
CI / frontend (push) Failing after 36s
CI / reuse (push) Failing after 1s
The bookmark fix addressed one instance of a defect the TypeScript migration left across the feature entries. app.js stopped being a classic script, so its top-level declarations are no longer shared globals, but the converted entries kept reading them as window properties that nothing publishes. Restore the broken behavior: - ais, aprs, hf-aprs read serverLat, serverLon and haversineKm as undefined, so every positioned packet rendered an empty distance. - ais, aprs, hf-aprs, cw, sat, vdes, wefax, wspr called an undefined postPath, so clear-history and decoder toggles threw. - scheduler read authRole as undefined, so the lazy-load path never self-initialized and the Settings tab opened an inert scheduler. - background-decode read authEnabled as undefined, so control gating fell back to role-only. - vchan read fifteen application values and services as undefined: mode and bandwidth sync, the out-of-band hint, RX audio restart, and the frequency field all silently no-opped on a virtual channel. - vchan wrapped window.refreshFreqDisplay, capturing an undefined original exactly as it did for setRigFrequency, so leaving a channel never restored the application's own frequency display. - _audioChannelOverride was a const that nothing could assign, so RX audio always subscribed to the primary channel. - ftx-family read fmtTime, a helper legacy ft8.js owned locally, so decode bar timestamps rendered empty. Declare the contract once in plugins/host.ts and import it from the feature entries, rather than restoring globals that docs/frontend-architecture.md excludes. trx.state gains jogUnit, rxActive and audioChannelOverride, and makes lastModeName writable; trx.core gains the tuning, RDS, WFM, jog and RX audio services the entries need. vchan interception moves to an interceptFreqDisplay service method that refreshFreqDisplay calls, matching the frequency, mode and bandwidth interception it already registers. Reading registry-built elements through a strict lookup is the same defect as in bookmarks: renderTimelineNeedle guards its result, but schedulerEl throws, so the now-initializing scheduler crashed on the timeline needle group that its own SVG creates. Feature tests move onto a shared host fixture, and entries that now import a common module are bundled through bundleEntry like the other shared-module entries. Covers scheduler self-initialization and the distance path that the bare window reads broke. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GdyUjuXejCEfiub675z6cz Signed-off-by: Stan Grams <sjg@haxx.space>
337 lines
12 KiB
JavaScript
337 lines
12 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 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 ? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>` : "";
|
|
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 = `<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);
|
|
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();
|
|
if (msg.lat != null && msg.lon != null && aisWindow.aisMapAddVessel) {
|
|
aisWindow.aisMapAddVessel(msg);
|
|
}
|
|
}
|
|
function normalizeServerAisMessage(msg) {
|
|
return {
|
|
...msg,
|
|
rig_id: msg.rig_id || null
|
|
};
|
|
}
|
|
function onServerAisBatch(messages) {
|
|
if (!Array.isArray(messages) || messages.length === 0) return;
|
|
if (aisStatus) aisStatus.textContent = "Receiving";
|
|
const normalized = [];
|
|
for (const msg of messages) {
|
|
const next = normalizeServerAisMessage(msg);
|
|
const tsMs = Number.isFinite(next.ts_ms) ? Number(next.ts_ms) : Date.now();
|
|
next._tsMs = tsMs;
|
|
next._ts = new Date(tsMs).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit"
|
|
});
|
|
if (next.lat != null && next.lon != null && aisWindow.aisMapAddVessel) {
|
|
aisWindow.aisMapAddVessel(next);
|
|
}
|
|
normalized.push(next);
|
|
}
|
|
normalized.reverse();
|
|
aisMessageHistory = normalized.concat(aisMessageHistory);
|
|
pruneAisMessageHistory();
|
|
scheduleAisBarUpdate();
|
|
scheduleAisHistoryRender();
|
|
}
|
|
function pruneAisHistoryView() {
|
|
pruneAisMessageHistory();
|
|
updateAisBar();
|
|
renderAisHistory();
|
|
}
|
|
document.getElementById("settings-clear-ais-history")?.addEventListener("click", () => {
|
|
void (async () => {
|
|
if (!await aisWindow.trxUi.confirm({ title: "Clear AIS history?", message: "All stored AIS messages will be permanently removed.", confirmLabel: "Clear history" })) return;
|
|
try {
|
|
await 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
|
|
});
|