diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index 401120f..f47c081 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -218,6 +218,7 @@ function applyAuthRestrictions() {
// Disable plugin enable/disable buttons and decode history clear buttons
// Note: sig-clear-btn is allowed for RX (clears local measurements only)
const pluginToggleBtns = [
+ "ais-clear-btn",
"ft8-decode-toggle-btn",
"wspr-decode-toggle-btn",
"cw-auto",
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 3d4d0de..cee6801 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -425,6 +425,20 @@
Waiting for server decode
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js
index cd9df69..c764b0d 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js
@@ -4,46 +4,176 @@ const aisMessagesEl = document.getElementById("ais-messages");
const aisFilterInput = document.getElementById("ais-filter");
const aisClearBtn = document.getElementById("ais-clear-btn");
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_MAX_MESSAGES = 200;
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 = [];
+function formatAisMhz(freqHz) {
+ return `${(freqHz / 1_000_000).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") {
+ 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 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 (!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";
+ 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 aisLatestByVessel(messages) {
+ 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);
+ }
+ 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 || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
+ const ts = msg._ts || new Date().toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
const name = aisDisplayName(msg);
- const channel = msg.channel ? `AIS-${msg.channel}` : "AIS";
+ const channel = aisChannelInfo(msg.channel);
+ const motion = aisMotionText(msg);
+ const route = aisRouteText(msg);
const pos = msg.lat != null && msg.lon != null
- ? `
${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`
+ ? `
${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`
: "";
- const motion = [
- msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
- msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
- ].filter(Boolean).join(" · ");
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}` +
- `
${escapeMapHtml(name)} ` +
- `
[${escapeMapHtml(channel)}] ` +
- `
MMSI ${escapeMapHtml(String(msg.mmsi))}` +
- (motion ? `
${escapeMapHtml(motion)}` : "") +
- pos;
+ `
` +
+ `${ts}` +
+ `${escapeMapHtml(name)}` +
+ `${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`) +
+ (pos ? `${pos}` : "") +
+ `${escapeMapHtml(aisAgeText(msg._tsMs))}` +
+ `
`;
applyAisFilterToRow(row);
return row;
}
@@ -65,9 +195,12 @@ function applyAisFilterToAll() {
function updateAisBar() {
if (!aisBarOverlay) return;
+ updateAisSummary();
+
const isAis = (document.getElementById("mode")?.value || "").toUpperCase() === "AIS";
const cutoffMs = Date.now() - AIS_BAR_WINDOW_MS;
- const messages = aisMessageHistory.filter((msg) => msg._tsMs >= cutoffMs);
+ 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 = "";
@@ -81,14 +214,18 @@ function updateAisBar() {
? `
`
: "";
const name = `
${escapeMapHtml(aisDisplayName(msg))}`;
- const channel = msg.channel ? ` AIS-${escapeMapHtml(msg.channel)}` : "";
+ const channel = aisChannelInfo(msg.channel);
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,
- ].filter(Boolean).join(" · ");
+ escapeMapHtml(aisAgeText(msg._tsMs)),
+ ]
+ .filter(Boolean)
+ .join(" · ");
html += `
` +
- `
${ts}${pin}${name}${channel}: ${details}
` +
+ `
${ts}${pin}${name}: ${details}
` +
`
`;
}
aisBarOverlay.innerHTML = html;
@@ -96,7 +233,7 @@ function updateAisBar() {
}
window.updateAisBar = updateAisBar;
window.clearAisBar = function() {
- window.resetAisHistoryView();
+ document.getElementById("ais-clear-btn")?.click();
};
window.resetAisHistoryView = function() {
@@ -109,7 +246,11 @@ window.resetAisHistoryView = function() {
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" });
+ msg._ts = new Date(tsMs).toLocaleTimeString([], {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: "2-digit",
+ });
aisMessageHistory.unshift(msg);
if (aisMessageHistory.length > AIS_MAX_MESSAGES) aisMessageHistory.length = AIS_MAX_MESSAGES;
@@ -163,3 +304,5 @@ window.onServerAis = function(msg) {
ts_ms: msg.ts_ms,
});
};
+
+updateAisSummary();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index c503375..9c8b45c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
@@ -1088,16 +1088,99 @@ small { color: var(--text-muted); }
.sub-tab:hover:not(.active) { color: var(--text); }
#aprs-map { min-height: 150px; border-radius: 6px; }
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
+.ais-summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.6rem;
+ margin-bottom: 0.75rem;
+}
+.ais-summary-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.18rem;
+ padding: 0.45rem 0.55rem;
+ border: 1px solid var(--border-light);
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--card-bg) 84%, transparent);
+}
+.ais-summary-label {
+ color: var(--text-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.ais-summary-value {
+ color: var(--text);
+ font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
+ font-size: 0.8rem;
+ line-height: 1.3;
+}
#aprs-packets,
#ais-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
.aprs-packet:last-child { border-bottom: none; }
-.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
+.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
.ais-message:last-child { border-bottom: none; }
.aprs-call { color: var(--accent-green); font-weight: 600; }
.ais-call { color: var(--accent-red); font-weight: 600; }
.aprs-time { color: var(--text-muted); margin-right: 0.5rem; }
.ais-time { color: var(--text-muted); margin-right: 0.5rem; }
+.ais-row-head,
+.ais-row-meta,
+.ais-row-detail {
+ display: flex;
+ align-items: center;
+ gap: 0.45rem;
+ flex-wrap: wrap;
+}
+.ais-row-head + .ais-row-meta,
+.ais-row-meta + .ais-row-detail {
+ margin-top: 0.2rem;
+}
+.ais-row-detail {
+ color: var(--text-muted);
+ font-size: 0.78rem;
+}
+.ais-badge {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 1.2rem;
+ padding: 0.02rem 0.38rem;
+ border-radius: 999px;
+ border: 1px solid color-mix(in srgb, var(--border-light) 78%, transparent);
+ background: color-mix(in srgb, var(--card-bg) 72%, transparent);
+ color: var(--text-muted);
+ font-size: 0.68rem;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+.ais-badge-channel-a {
+ color: #77d6a5;
+ border-color: color-mix(in srgb, #77d6a5 42%, transparent);
+ background: color-mix(in srgb, #77d6a5 12%, transparent);
+}
+.ais-badge-channel-b {
+ color: #ff9a7a;
+ border-color: color-mix(in srgb, #ff9a7a 42%, transparent);
+ background: color-mix(in srgb, #ff9a7a 14%, transparent);
+}
+.ais-badge-type {
+ color: var(--text);
+}
+.ais-meta-text {
+ color: var(--text-muted);
+ font-size: 0.76rem;
+}
+.ais-pos-link {
+ color: var(--accent-red);
+ text-decoration: none;
+}
+.ais-pos-link:hover {
+ text-decoration: underline;
+}
.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; }
.aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; }
.aprs-pos:hover { text-decoration: underline; }
@@ -1448,6 +1531,9 @@ button:focus-visible, input:focus-visible, select:focus-visible {
flex-wrap: wrap;
align-items: stretch;
}
+ .ais-summary {
+ grid-template-columns: minmax(0, 1fr);
+ }
.aprs-controls > button,
.ft8-controls > button,
.cw-controls > button {
@@ -1694,21 +1780,26 @@ button:focus-visible, input:focus-visible, select:focus-visible {
.spectrum-bookmark-chip-side .spectrum-bookmark-name {
display: block;
max-width: 100%;
- overflow: hidden;
- text-overflow: ellipsis;
+ min-width: 0;
}
.spectrum-bookmark-chip-side .spectrum-bookmark-freq {
- min-width: 0;
font-size: 0.54rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
opacity: 0.9;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
}
.spectrum-bookmark-chip-side .spectrum-bookmark-name {
width: 100%;
font-size: 0.64rem;
font-weight: 600;
+ white-space: normal;
+ overflow-wrap: anywhere;
+ word-break: break-word;
+ line-height: 1.22;
}
.bm-icon-svg path {
fill: var(--bm-cat-fg, #1a202c);