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 521191e7..91f47f32 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 @@ -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) { - const row = document.createElement("div"); + const row = document.createElement("details"); row.className = "ais-message"; const ts = msg._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", @@ -174,7 +184,8 @@ function renderAisRow(msg) { 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)}` : ""; + const pos = msg.lat != null && msg.lon != null ? `${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}` : ""; + const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null; row.dataset.filterText = [ name, msg.mmsi, @@ -185,7 +196,16 @@ function renderAisRow(msg) { 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))}
`; + row.innerHTML = `${escapeAisHtml(ts)}${nameHtml}${escapeAisHtml(aisTypeLabel(msg.message_type))}${escapeAisHtml(aisSummaryText(msg))}${escapeAisHtml(channel.label)}` + (distance ? `${escapeAisHtml(distance)}` : "") + `
MMSI ${escapeAisHtml(String(msg.mmsi))}${escapeAisHtml(channel.freqText)}` + (route ? `${escapeAisHtml(route)}` : "") + (motion ? `${escapeAisHtml(motion)}` : "") + `${escapeAisHtml(aisAgeText(msg._tsMs))}` + (pos ? `${pos}` : "") + `
` + (msg.lat != null && msg.lon != null ? `` : "") + (vesselUrl ? `Vessel` : "") + `
`; + 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); return row; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js index d8643464..f6907927 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/aprs.js @@ -4,7 +4,7 @@ import { collapseAprsDuplicates, normalizeAprsPacket, renderAprsPacketRow -} from "./chunk-YAWVT7YC.js"; +} from "./chunk-OZI7SINT.js"; import { hostCore, hostState diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OZI7SINT.js similarity index 84% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OZI7SINT.js index 3d8ea2e7..6a6a5faf 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-OZI7SINT.js @@ -221,7 +221,7 @@ function renderAprsPacketRow(packet, options = {}) { const summary = summarizeAprsPayload(packet); const hasPosition = packet.lat != null && packet.lon != null; const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; - row.innerHTML = `${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ
`; + row.innerHTML = `${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}${escapeAprsHtml(aprsCategoryLabel(category))}${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `
>${escapeAprsHtml(packet.destCall || "--")}${escapeAprsHtml(packet.path || "no path")}${escapeAprsHtml(aprsAgeText(packet._tsMs))}CRC ${packet.crcOk ? "ok" : "failed"}` + (hasPosition ? `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + (hasPosition ? `` : "") + `QRZ
`; row.querySelectorAll("[data-aprs-map]").forEach((element) => { element.addEventListener("click", (event) => { event.preventDefault(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js index 8ac8bbed..3d504ec0 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/hf-aprs.js @@ -4,7 +4,7 @@ import { collapseAprsDuplicates, normalizeAprsPacket, renderAprsPacketRow -} from "./chunk-YAWVT7YC.js"; +} from "./chunk-OZI7SINT.js"; import { hostCore, hostState diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js index 759ebf1b..dd456e9a 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/map-core.js @@ -1,6 +1,6 @@ import { aprsSymbolSprite -} from "./chunk-YAWVT7YC.js"; +} from "./chunk-OZI7SINT.js"; // src/map-core.ts function mapEl(id) { 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 600d963e..7b454294 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 @@ -680,19 +680,12 @@ SPDX-License-Identifier: GPL-2.0-or-later Waiting for server decode -
-
- Channels - A 161.975 MHz · B 162.025 MHz -
-
- Tracked - 0 vessels -
-
- Latest - No traffic yet -
+
+ + 0 vessels + No traffic yet + A 161.975 MHz · B 162.025 MHz +
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 7cda2aac..2da772a8 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 @@ -2844,12 +2844,12 @@ body.map-fake-fullscreen-active { background: var(--input-bg); 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 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:last-child { border-bottom: none; } -.aprs-line { +.decode-line { display: flex; align-items: center; gap: 0.5rem; @@ -2858,47 +2858,66 @@ body.map-fake-fullscreen-active { list-style: none; white-space: nowrap; } -.aprs-line::-webkit-details-marker { display: none; } -.aprs-line:hover { +.decode-line::-webkit-details-marker { display: none; } +.decode-line:hover { 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); } +/* 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 whatever its payload, so the column of times and callsigns stays readable. */ -.aprs-line-summary { +.decode-line-summary { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--text); } -.aprs-line-distance { +.decode-line-distance { flex: 0 0 auto; color: var(--text-muted); font-size: 0.75rem; } -.aprs-expanded { +.decode-expanded { display: flex; flex-direction: column; gap: 0.35rem; padding: 0.1rem 0.55rem 0.55rem 2.6rem; } -.aprs-expanded-meta { +.decode-expanded-meta { display: flex; flex-wrap: wrap; gap: 0.6rem; font-size: 0.75rem; color: var(--text-muted); } -.aprs-expanded-raw, -.aprs-expanded-bytes { +.decode-expanded-raw, +.decode-expanded-bytes { word-break: break-all; font-size: 0.78rem; color: var(--text); } -.aprs-expanded-bytes { +.decode-expanded-bytes { color: var(--text-muted); font-size: 0.72rem; } @@ -3025,7 +3044,9 @@ body.map-fake-fullscreen-active { color: var(--text); 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; } .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; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts index 6a7034ed..7b6e15e3 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/ais.ts @@ -27,6 +27,7 @@ interface AisMessage { } interface AisChannelInfo { label: string; badgeClass: string; freqText: string } interface AisBridge { + navigateToAprsMap?: (lat: number, lon: number) => void; getDecodeHistoryRetentionMs?: () => number; trxScheduleUiFrameJob?: (key: string, job: () => void) => void; 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 { - const row = document.createElement("div"); + const row = document.createElement("details"); row.className = "ais-message"; const ts = msg._ts || new Date().toLocaleTimeString([], { hour: "2-digit", @@ -227,8 +241,9 @@ function renderAisRow(msg: AisMessage): HTMLElement { const route = aisRouteText(msg); const distance = aisDistanceText(msg); const pos = msg.lat != null && msg.lon != null - ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` + ? `${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}` : ""; + const vesselUrl = aisWindow.buildAisVesselUrl?.(msg.mmsi) ?? null; row.dataset.filterText = [ name, msg.mmsi, @@ -243,23 +258,43 @@ function renderAisRow(msg: AisMessage): HTMLElement { .join(" ") .toUpperCase(); row.innerHTML = - `
` + - `${ts}` + + `` + + `${escapeAisHtml(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))}` + + `${escapeAisHtml(aisSummaryText(msg))}` + + `${escapeAisHtml(channel.label)}` + + (distance ? `${escapeAisHtml(distance)}` : "") + + `` + + `
` + + `
` + + `MMSI ${escapeAisHtml(String(msg.mmsi))}` + + `${escapeAisHtml(channel.freqText)}` + + (route ? `${escapeAisHtml(route)}` : "") + + (motion ? `${escapeAisHtml(motion)}` : "") + + `${escapeAisHtml(aisAgeText(msg._tsMs))}` + + (pos ? `${pos}` : "") + + `
` + + `
` + + (msg.lat != null && msg.lon != null + ? `` + : "") + + (vesselUrl + ? `Vessel` + : "") + + `
` + `
`; + + 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); return row; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts index cf7c00d2..8ef8517f 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs-shared.ts @@ -333,19 +333,19 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(packet.srcCall || "")}`; row.innerHTML = - `` + + `` + `${escapeAprsHtml(time)}` + (options.badge ? `${escapeAprsHtml(options.badge)}` : "") + renderLocalAprsSymbol(packet, escapeAprsHtml) + `${escapeAprsHtml(packet.srcCall ?? "")}` + `` + `${escapeAprsHtml(aprsCategoryLabel(category))}` + - `${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + + `${summary ? escapeAprsHtml(summary) : renderAprsInfo(packet)}` + (packet.crcOk ? "" : 'CRC') + - (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + + (options.distance ? `${escapeAprsHtml(options.distance)}` : "") + `` + - `
` + - `
` + + `
` + + `
` + `>${escapeAprsHtml(packet.destCall || "--")}` + `${escapeAprsHtml(packet.path || "no path")}` + `${escapeAprsHtml(aprsAgeText(packet._tsMs))}` + @@ -355,9 +355,9 @@ export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions + `${packet.lat?.toFixed(5)}, ${packet.lon?.toFixed(5)}` : "") + `
` + - `
${renderAprsInfo(packet)}
` + + `
${renderAprsInfo(packet)}
` + (packet.info_bytes?.length - ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` + ? `
${escapeAprsHtml(aprsHexBytes(packet.info_bytes))}
` : "") + `
` + (hasPosition ? `` : "") + diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs index ee21064b..f021d128 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/decode-flow.mjs @@ -178,7 +178,20 @@ const APRS_FRAMES = [ ...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); try { @@ -194,7 +207,7 @@ try { tag: row.tagName, height: Math.round(row.getBoundingClientRect().height), 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. 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. 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); const expanded = await aprs.page.evaluate(() => { const row = document.querySelector("#aprs-packets .aprs-packet[open]"); return { open: row.hasAttribute("open"), - raw: row.querySelector(".aprs-expanded-raw")?.textContent?.trim() ?? "", - meta: row.querySelector(".aprs-expanded-meta")?.textContent ?? "", + raw: row.querySelector(".decode-expanded-raw")?.textContent?.trim() ?? "", + meta: row.querySelector(".decode-expanded-meta")?.textContent ?? "", }; }); assert.equal(expanded.open, true, "the frame did not open"); assert.match(expanded.raw, /^_10090556c220s004g005t077/, `raw frame: ${expanded.raw}`); 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, []); } finally { await aprs.browser.close();