[style](trx-frontend): polish AIS panel and bookmark wrapping
Refine the AIS plugin tab with summary cards, clearer vessel rows, and better live-bar deduping, and let long side bookmark names wrap cleanly inside their chips. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -425,6 +425,20 @@
|
||||
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" />
|
||||
<small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
|
||||
</div>
|
||||
<div class="ais-summary">
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Channels</span>
|
||||
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Tracked</span>
|
||||
<span id="ais-vessel-count" class="ais-summary-value">0 vessels</span>
|
||||
</div>
|
||||
<div class="ais-summary-card">
|
||||
<span class="ais-summary-label">Latest</span>
|
||||
<span id="ais-latest-seen" class="ais-summary-value">No traffic yet</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="ais-messages"></div>
|
||||
</div>
|
||||
<div id="subtab-aprs" class="sub-tab-panel" style="display:none;">
|
||||
|
||||
@@ -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
|
||||
? ` <a class="aprs-pos" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||
? `<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>`
|
||||
: "";
|
||||
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 =
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<span class="ais-call">${escapeMapHtml(name)}</span> ` +
|
||||
`<span class="aprs-time">[${escapeMapHtml(channel)}]</span> ` +
|
||||
`<span>MMSI ${escapeMapHtml(String(msg.mmsi))}</span>` +
|
||||
(motion ? ` <span>${escapeMapHtml(motion)}</span>` : "") +
|
||||
pos;
|
||||
`<div class="ais-row-head">` +
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<span class="ais-call">${escapeMapHtml(name)}</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>`) +
|
||||
(pos ? `<span>${pos}</span>` : "") +
|
||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||
`</div>`;
|
||||
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() {
|
||||
? `<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">${escapeMapHtml(aisDisplayName(msg))}</span>`;
|
||||
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 += `<div class="aprs-bar-frame">` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${name}${channel}: ${details}</div>` +
|
||||
`<div class="aprs-bar-frame-main">${ts}${pin}${name}: ${details}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user