[feat](trx-frontend-http): give the AIS list the same log shape
CI / lint (push) Successful in 2m15s
CI / test (push) Successful in 8m8s
CI / frontend (push) Successful in 3m41s
CI / reuse (push) Successful in 2s

A message was three stacked lines — time and name, then MMSI and route,
then motion, distance, position and age — so a screen held eight of
them.  It is one line now: time, vessel, message type, and what the
message says, opening in place for the MMSI, the channel frequency, the
route, the age, the fix and a jump to the map.  Twenty-two fit where
eight did.

What a message says depends on what it is.  Position reports give the
fix and the motion; the static and voyage reports that carry no fix give
the callsign and where the vessel is bound.  Both fall back to whatever
fields are present rather than showing nothing.

The row vocabulary the APRS list introduced is no longer APRS-specific —
the classes are decode-line and decode-expanded now, shared by both, and
identity sits in fixed columns so the summaries line up down the list
instead of starting wherever the callsign happens to end.  The three
summary cards above the list go the way of the APRS ones.

Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
sjg
2026-08-04 23:24:09 +02:00
parent 37987b2779
commit 6d25ecdc11
10 changed files with 172 additions and 61 deletions
@@ -160,8 +160,18 @@ function updateAisSummary() {
} }
} }
} }
function aisSummaryText(msg) {
const parts = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg) { function renderAisRow(msg) {
const row = document.createElement("div"); const row = document.createElement("details");
row.className = "ais-message"; row.className = "ais-message";
const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@@ -174,7 +184,8 @@ function renderAisRow(msg) {
const motion = aisMotionText(msg); const motion = aisMotionText(msg);
const route = aisRouteText(msg); const route = aisRouteText(msg);
const distance = aisDistanceText(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>` : ""; const pos = msg.lat != null && msg.lon != null ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>` : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [ row.dataset.filterText = [
name, name,
msg.mmsi, msg.mmsi,
@@ -185,7 +196,16 @@ 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}">${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>`; row.innerHTML = `<summary class="decode-line"><span class="ais-time">${escapeAisHtml(ts)}</span><span class="ais-call">${nameHtml}</span><span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span><span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span><span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span><span>${escapeAisHtml(channel.freqText)}</span>` + (route ? `<span>${escapeAisHtml(route)}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + (pos ? `<span>${pos}</span>` : "") + `</div><div class="aprs-row-actions">` + (msg.lat != null && msg.lon != null ? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>` : "") + (vesselUrl ? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>` : "") + `</div></div>`;
row.querySelectorAll("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row); applyAisFilterToRow(row);
return row; return row;
} }
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates, collapseAprsDuplicates,
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-YAWVT7YC.js"; } from "./chunk-OZI7SINT.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -221,7 +221,7 @@ function renderAprsPacketRow(packet, options = {}) {
const summary = summarizeAprsPayload(packet); const summary = summarizeAprsPayload(packet);
const hasPosition = packet.lat != null && packet.lon != null; const hasPosition = packet.lat != null && packet.lon != null;
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = `<summary class="aprs-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="aprs-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="aprs-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="aprs-expanded"><div class="aprs-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="aprs-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="aprs-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`; row.innerHTML = `<summary class="decode-line"><span class="aprs-time">${escapeAprsHtml(time)}</span>` + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span><span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">${escapeAprsHtml(aprsCategoryLabel(category))}</span><span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + `</summary><div class="decode-expanded"><div class="decode-expanded-meta"><span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span><span>${escapeAprsHtml(packet.path || "no path")}</span><span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span><span>CRC ${packet.crcOk ? "ok" : "failed"}</span>` + (hasPosition ? `<a class="aprs-pos" href="javascript:void(0)" data-aprs-map="${packet.lat},${packet.lon}">${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` : "") + `</div><div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` + (packet.info_bytes?.length ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` : "") + `<div class="aprs-row-actions">` + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-copy="${packet.lat},${packet.lon}">Copy Coords</button>` : "") + `<a class="aprs-inline-btn" href="${qrzHref}" target="_blank" rel="noopener">QRZ</a></div></div>`;
row.querySelectorAll("[data-aprs-map]").forEach((element) => { row.querySelectorAll("[data-aprs-map]").forEach((element) => {
element.addEventListener("click", (event) => { element.addEventListener("click", (event) => {
event.preventDefault(); event.preventDefault();
@@ -4,7 +4,7 @@ import {
collapseAprsDuplicates, collapseAprsDuplicates,
normalizeAprsPacket, normalizeAprsPacket,
renderAprsPacketRow renderAprsPacketRow
} from "./chunk-YAWVT7YC.js"; } from "./chunk-OZI7SINT.js";
import { import {
hostCore, hostCore,
hostState hostState
@@ -1,6 +1,6 @@
import { import {
aprsSymbolSprite aprsSymbolSprite
} from "./chunk-YAWVT7YC.js"; } from "./chunk-OZI7SINT.js";
// src/map-core.ts // src/map-core.ts
function mapEl(id) { function mapEl(id) {
@@ -680,19 +680,12 @@ SPDX-License-Identifier: GPL-2.0-or-later
<input id="ais-filter" class="ft8-filter" type="text" placeholder="Filter (e.g. MMSI, vessel, A)" /> <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> <small id="ais-status" style="color:var(--text-muted);">Waiting for server decode</small>
</div> </div>
<div class="ais-summary"> <div class="aprs-filter-row">
<div class="ais-summary-card"> <span class="aprs-counts">
<span class="ais-summary-label">Channels</span> <span id="ais-vessel-count" class="aprs-counts-value">0 vessels</span>
<span id="ais-channel-summary" class="ais-summary-value">A 161.975 MHz · B 162.025 MHz</span> <span id="ais-latest-seen" class="aprs-counts-value">No traffic yet</span>
</div> <span id="ais-channel-summary" class="aprs-counts-value">A 161.975 MHz · B 162.025 MHz</span>
<div class="ais-summary-card"> </span>
<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>
<div id="ais-messages"></div> <div id="ais-messages"></div>
</div> </div>
@@ -2844,12 +2844,12 @@ body.map-fake-fullscreen-active {
background: var(--input-bg); background: var(--input-bg);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
} }
/* One line per frame, opening in place. A frame used to be a card five lines /* One line per decode, opening in place — shared by the APRS and AIS lists. A frame used to be a card five lines
tall — timestamp, meta, raw information field, three buttons, a Details tall — timestamp, meta, raw information field, three buttons, a Details
panel repeating the row — so five of them filled the panel. */ panel repeating the row — so five of them filled the panel. */
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; border-bottom: 1px solid var(--border); line-height: 1.35; } .aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
.aprs-packet:last-child { border-bottom: none; } .aprs-packet:last-child { border-bottom: none; }
.aprs-line { .decode-line {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -2858,47 +2858,66 @@ body.map-fake-fullscreen-active {
list-style: none; list-style: none;
white-space: nowrap; white-space: nowrap;
} }
.aprs-line::-webkit-details-marker { display: none; } .decode-line::-webkit-details-marker { display: none; }
.aprs-line:hover { .decode-line:hover {
background: color-mix(in srgb, var(--btn-bg) 45%, transparent); background: color-mix(in srgb, var(--btn-bg) 45%, transparent);
} }
.aprs-packet[open] > .aprs-line { .aprs-packet[open] > .decode-line {
background: color-mix(in srgb, var(--accent-green) 8%, transparent); background: color-mix(in srgb, var(--accent-green) 8%, transparent);
} }
/* Fixed columns for what identifies a decode, so the summaries line up down
the list and the eye can run along one of them instead of hunting. Names
longer than the column ellipsise rather than pushing the rest along. */
.decode-line .aprs-call,
.decode-line .ais-call {
flex: 0 0 auto;
width: 8.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.decode-line .aprs-badge-type {
flex: 0 0 auto;
min-width: 5.5rem;
}
.decode-line .ais-badge-type {
flex: 0 0 auto;
min-width: 9rem;
}
/* The summary takes the leftover width and ellipsises: a frame is one line /* The summary takes the leftover width and ellipsises: a frame is one line
whatever its payload, so the column of times and callsigns stays readable. */ whatever its payload, so the column of times and callsigns stays readable. */
.aprs-line-summary { .decode-line-summary {
flex: 1 1 auto; flex: 1 1 auto;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
color: var(--text); color: var(--text);
} }
.aprs-line-distance { .decode-line-distance {
flex: 0 0 auto; flex: 0 0 auto;
color: var(--text-muted); color: var(--text-muted);
font-size: 0.75rem; font-size: 0.75rem;
} }
.aprs-expanded { .decode-expanded {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.35rem; gap: 0.35rem;
padding: 0.1rem 0.55rem 0.55rem 2.6rem; padding: 0.1rem 0.55rem 0.55rem 2.6rem;
} }
.aprs-expanded-meta { .decode-expanded-meta {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.6rem; gap: 0.6rem;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--text-muted); color: var(--text-muted);
} }
.aprs-expanded-raw, .decode-expanded-raw,
.aprs-expanded-bytes { .decode-expanded-bytes {
word-break: break-all; word-break: break-all;
font-size: 0.78rem; font-size: 0.78rem;
color: var(--text); color: var(--text);
} }
.aprs-expanded-bytes { .decode-expanded-bytes {
color: var(--text-muted); color: var(--text-muted);
font-size: 0.72rem; font-size: 0.72rem;
} }
@@ -3025,7 +3044,9 @@ body.map-fake-fullscreen-active {
color: var(--text); color: var(--text);
word-break: break-word; word-break: break-word;
} }
.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 { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
.ais-message > .decode-line:hover { background: color-mix(in srgb, var(--btn-bg) 45%, transparent); }
.ais-message[open] > .decode-line { background: color-mix(in srgb, var(--accent-red) 8%, transparent); }
.ais-message:last-child { border-bottom: none; } .ais-message:last-child { border-bottom: none; }
.vdes-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; } .vdes-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; }
.vdes-message:last-child { border-bottom: none; } .vdes-message:last-child { border-bottom: none; }
@@ -27,6 +27,7 @@ interface AisMessage {
} }
interface AisChannelInfo { label: string; badgeClass: string; freqText: string } interface AisChannelInfo { label: string; badgeClass: string; freqText: string }
interface AisBridge { interface AisBridge {
navigateToAprsMap?: (lat: number, lon: number) => void;
getDecodeHistoryRetentionMs?: () => number; getDecodeHistoryRetentionMs?: () => number;
trxScheduleUiFrameJob?: (key: string, job: () => void) => void; trxScheduleUiFrameJob?: (key: string, job: () => void) => void;
buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null; buildAisVesselUrl?: (mmsi: number | null | undefined) => string | null;
@@ -212,8 +213,21 @@ function updateAisSummary() {
} }
} }
/** What the message says, in one line: where the vessel is and what it is
* doing, or for the static reports that carry no fix where it is going. */
function aisSummaryText(msg: AisMessage): string {
const parts: string[] = [];
if (msg.lat != null && msg.lon != null) parts.push(`${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}`);
const motion = aisMotionText(msg);
if (motion) parts.push(motion);
const route = aisRouteText(msg);
if (route && parts.length < 2) parts.push(route);
if (!parts.length) return route || "no position reported";
return parts.join(" · ");
}
function renderAisRow(msg: AisMessage): HTMLElement { function renderAisRow(msg: AisMessage): HTMLElement {
const row = document.createElement("div"); const row = document.createElement("details");
row.className = "ais-message"; row.className = "ais-message";
const ts = msg._ts || new Date().toLocaleTimeString([], { const ts = msg._ts || new Date().toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
@@ -227,8 +241,9 @@ function renderAisRow(msg: AisMessage): HTMLElement {
const route = aisRouteText(msg); const route = aisRouteText(msg);
const distance = aisDistanceText(msg); const distance = aisDistanceText(msg);
const pos = msg.lat != null && msg.lon != null 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>` ? `<a class="ais-pos-link" href="javascript:void(0)" data-ais-map="${msg.lat},${msg.lon}">${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</a>`
: ""; : "";
const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null;
row.dataset.filterText = [ row.dataset.filterText = [
name, name,
msg.mmsi, msg.mmsi,
@@ -243,23 +258,43 @@ function renderAisRow(msg: AisMessage): HTMLElement {
.join(" ") .join(" ")
.toUpperCase(); .toUpperCase();
row.innerHTML = row.innerHTML =
`<div class="ais-row-head">` + `<summary class="decode-line">` +
`<span class="ais-time">${ts}</span>` + `<span class="ais-time">${escapeAisHtml(ts)}</span>` +
`<span class="ais-call">${nameHtml}</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>` + `<span class="ais-badge ais-badge-type">${escapeAisHtml(aisTypeLabel(msg.message_type))}</span>` +
`</div>` + `<span class="decode-line-summary">${escapeAisHtml(aisSummaryText(msg))}</span>` +
`<div class="ais-row-meta">` + `<span class="${channel.badgeClass}">${escapeAisHtml(channel.label)}</span>` +
`<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` + (distance ? `<span class="decode-line-distance">${escapeAisHtml(distance)}</span>` : "") +
(route ? `<span class="ais-meta-text">${escapeAisHtml(route)}</span>` : "") + `</summary>` +
`<span class="ais-meta-text">${escapeAisHtml(channel.freqText)}</span>` + `<div class="decode-expanded">` +
`</div>` + `<div class="decode-expanded-meta">` +
`<div class="ais-row-detail">` + `<span>MMSI ${escapeAisHtml(String(msg.mmsi))}</span>` +
(motion ? `<span>${escapeAisHtml(motion)}</span>` : `<span>No motion data</span>`) + `<span>${escapeAisHtml(channel.freqText)}</span>` +
(distance ? `<span>${escapeAisHtml(distance)}</span>` : "") + (route ? `<span>${escapeAisHtml(route)}</span>` : "") +
(pos ? `<span>${pos}</span>` : "") + (motion ? `<span>${escapeAisHtml(motion)}</span>` : "") +
`<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` + `<span>${escapeAisHtml(aisAgeText(msg._tsMs))}</span>` +
(pos ? `<span>${pos}</span>` : "") +
`</div>` +
`<div class="aprs-row-actions">` +
(msg.lat != null && msg.lon != null
? `<button class="aprs-inline-btn" type="button" data-ais-map="${msg.lat},${msg.lon}">Map</button>`
: "") +
(vesselUrl
? `<a class="aprs-inline-btn" href="${escapeAisHtml(vesselUrl)}" target="_blank" rel="noopener">Vessel</a>`
: "") +
`</div>` +
`</div>`; `</div>`;
row.querySelectorAll<HTMLElement>("[data-ais-map]").forEach((element) => {
element.addEventListener("click", (event) => {
event.preventDefault();
event.stopPropagation();
const [lat, lon] = (element.dataset.aisMap ?? "").split(",").map(Number);
if (lat == null || lon == null || !Number.isFinite(lat) || !Number.isFinite(lon)) return;
aisWindow.navigateToAprsMap?.(lat, lon);
});
});
applyAisFilterToRow(row); applyAisFilterToRow(row);
return row; return row;
} }
@@ -333,19 +333,19 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`;
row.innerHTML = row.innerHTML =
`<summary class="aprs-line">` + `<summary class="decode-line">` +
`<span class="aprs-time">${escapeAprsHtml(time)}</span>` + `<span class="aprs-time">${escapeAprsHtml(time)}</span>` +
(options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") + (options.badge ? `<span class="aprs-badge aprs-badge-band">${escapeAprsHtml(options.badge)}</span>` : "") +
renderLocalAprsSymbol(packet, escapeAprsHtml) + renderLocalAprsSymbol(packet, escapeAprsHtml) +
`<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` + `<span class="aprs-call">${escapeAprsHtml(packet.srcCall ?? "")}</span>` +
`<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` + `<span class="aprs-badge aprs-badge-type aprs-badge-type-${category}">` +
`${escapeAprsHtml(aprsCategoryLabel(category))}</span>` + `${escapeAprsHtml(aprsCategoryLabel(category))}</span>` +
`<span class="aprs-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` + `<span class="decode-line-summary">${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}</span>` +
(packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') + (packet.crcOk ? "" : '<span class="aprs-badge aprs-badge-crc">CRC</span>') +
(options.distance ? `<span class="aprs-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") + (options.distance ? `<span class="decode-line-distance">${escapeAprsHtml(options.distance)}</span>` : "") +
`</summary>` + `</summary>` +
`<div class="aprs-expanded">` + `<div class="decode-expanded">` +
`<div class="aprs-expanded-meta">` + `<div class="decode-expanded-meta">` +
`<span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span>` + `<span>&gt;${escapeAprsHtml(packet.destCall || "--")}</span>` +
`<span>${escapeAprsHtml(packet.path || "no path")}</span>` + `<span>${escapeAprsHtml(packet.path || "no path")}</span>` +
`<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` + `<span>${escapeAprsHtml(aprsAgeText(packet._tsMs))}</span>` +
@@ -355,9 +355,9 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions
+ `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>` + `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}</a>`
: "") + : "") +
`</div>` + `</div>` +
`<div class="aprs-expanded-raw">${renderAprsInfo(packet)}</div>` + `<div class="decode-expanded-raw">${renderAprsInfo(packet)}</div>` +
(packet.info_bytes?.length (packet.info_bytes?.length
? `<div class="aprs-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>` ? `<div class="decode-expanded-bytes">${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}</div>`
: "") + : "") +
`<div class="aprs-row-actions">` + `<div class="aprs-row-actions">` +
(hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") + (hasPosition ? `<button class="aprs-inline-btn" type="button" data-aprs-map="${packet.lat},${packet.lon}">Map</button>` : "") +
@@ -178,7 +178,20 @@ const APRS_FRAMES = [
...frame, ...frame,
})); }));
const aprsFixture = await startWebFixture({ spectrum: true, history: { aprs: APRS_FRAMES } }); // The AIS list is the same shape: a line per message, saying where the vessel
// is and what it is doing, with the identifiers behind it.
const AIS_MESSAGES = [
{ message_type: 1, mmsi: 244660001, vessel_name: "NEDERLAND", channel: "A",
lat: 54.35, lon: 18.65, sog_knots: 8.2, cog_deg: 91.4 },
{ message_type: 5, mmsi: 244660002, vessel_name: "STENA SPIRIT", channel: "B",
callsign: "PBTX", destination: "GDANSK" },
].map((message, index) => ({ rig_id: "rig-a", ts_ms: Date.now() - index * 1000, ...message }));
const aprsFixture = await startWebFixture({
spectrum: true,
mode: "AIS",
history: { aprs: APRS_FRAMES, ais: AIS_MESSAGES },
});
const aprs = await startBrowser(chromium); const aprs = await startBrowser(chromium);
try { try {
@@ -194,7 +207,7 @@ try {
tag: row.tagName, tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height), height: Math.round(row.getBoundingClientRect().height),
type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "", type: row.querySelector(".aprs-badge-type")?.textContent?.trim() ?? "",
summary: row.querySelector(".aprs-line-summary")?.textContent?.trim() ?? "", summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
}))); })));
// Newest first, so find each by the type it carries rather than by position. // Newest first, so find each by the type it carries rather than by position.
const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? ""; const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? "";
@@ -213,20 +226,49 @@ try {
// The frame as it arrived is still there, one click away. // The frame as it arrived is still there, one click away.
await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" }) await aprs.page.locator("#aprs-packets .aprs-packet", { hasText: "25 °C" })
.locator(".aprs-line").first().click(); .locator(".decode-line").first().click();
await aprs.page.waitForTimeout(200); await aprs.page.waitForTimeout(200);
const expanded = await aprs.page.evaluate(() => { const expanded = await aprs.page.evaluate(() => {
const row = document.querySelector("#aprs-packets .aprs-packet[open]"); const row = document.querySelector("#aprs-packets .aprs-packet[open]");
return { return {
open: row.hasAttribute("open"), open: row.hasAttribute("open"),
raw: row.querySelector(".aprs-expanded-raw")?.textContent?.trim() ?? "", raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "",
meta: row.querySelector(".aprs-expanded-meta")?.textContent ?? "", meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "",
}; };
}); });
assert.equal(expanded.open, true, "the frame did not open"); assert.equal(expanded.open, true, "the frame did not open");
assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`); assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`);
assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`); assert.match(expanded.meta, /WIDE1-1/, `expanded meta: ${expanded.meta}`);
// AIS, on the same row.
await aprs.page.locator('.sub-tab[data-subtab="ais"]').click();
await aprs.page.waitForTimeout(300);
const aisRows = await aprs.page.evaluate(() =>
[...document.querySelectorAll("#ais-messages .ais-message")].map((row) => ({
tag: row.tagName,
height: Math.round(row.getBoundingClientRect().height),
name: row.querySelector(".ais-call")?.textContent?.trim() ?? "",
summary: row.querySelector(".decode-line-summary")?.textContent?.trim() ?? "",
})));
assert.equal(aisRows.length, AIS_MESSAGES.length, `rendered ${aisRows.length} messages`);
for (const row of aisRows) {
assert.equal(row.tag, "DETAILS", "an AIS message is not expandable in place");
assert.ok(row.height < 44, `an AIS message is ${row.height}px tall`);
}
const positionRow = aisRows.find((row) => row.name === "NEDERLAND");
const staticRow = aisRows.find((row) => row.name === "STENA SPIRIT");
assert.match(positionRow?.summary ?? "", /54\.3500, 18\.6500/, `position: ${positionRow?.summary}`);
assert.match(positionRow?.summary ?? "", /8\.2 kn/, `position: ${positionRow?.summary}`);
// A static report carries no fix, so it says where the vessel is going.
assert.match(staticRow?.summary ?? "", /PBTX -> GDANSK/, `static: ${staticRow?.summary}`);
await aprs.page.locator("#ais-messages .ais-message .decode-line").first().click();
await aprs.page.waitForTimeout(200);
const aisExpanded = await aprs.page.evaluate(() =>
document.querySelector("#ais-messages .ais-message[open] .decode-expanded-meta")?.textContent ?? "");
assert.match(aisExpanded, /MMSI 2446600/, `expanded AIS: ${aisExpanded}`);
assert.match(aisExpanded, /MHz/, `expanded AIS: ${aisExpanded}`);
assert.deepEqual(aprs.runtimeErrors, []); assert.deepEqual(aprs.runtimeErrors, []);
} finally { } finally {
await aprs.browser.close(); await aprs.browser.close();