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 fd3f223e..d8643464 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 @@ -1,13 +1,10 @@ import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol -} from "./chunk-ROTCLFXS.js"; + renderAprsPacketRow +} from "./chunk-YAWVT7YC.js"; import { hostCore, hostState @@ -114,51 +111,27 @@ function updateAprsChipState() { aprsHideCrcBtn?.classList.toggle("active", aprsHideCrc); aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); } -function renderAprsRow(pkt, isFresh) { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; - const distance = aprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - row.innerHTML = `
${ts}` + symbolHtml + `${escapeAprsHtml(pkt.srcCall ?? "")}>${escapeAprsHtml(pkt.destCall || "")}${escapeAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeAprsHtml(age)}` + (distance ? `${escapeAprsHtml(distance)}` : "") + `${escapeAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeAprsHtml(pkt.srcCall || "--")}Destination${escapeAprsHtml(pkt.destCall || "--")}Type${escapeAprsHtml(pkt.type || "--")}Path${escapeAprsHtml(pkt.path || "--")}Age${escapeAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeAprsHtml(pkt.info || "--")}Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; - row.querySelectorAll("[data-aprs-map]").forEach((el) => { - el.addEventListener("click", (evt) => { - evt.preventDefault(); - const raw = el.dataset.aprsMap ?? ""; - const [lat, lon] = raw.split(",").map(Number); - if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { - aprsWindow.navigateToAprsMap(lat, lon); - } - }); - }); - const copyBtn = row.querySelector("[data-aprs-copy]"); - if (copyBtn) { - copyBtn.addEventListener("click", () => { - void (async () => { - const raw = copyBtn.dataset.aprsCopy ?? ""; - try { - const clipboard = Reflect.get(navigator, "clipboard"); - if (clipboard) { - await clipboard.writeText(raw); - showAprsHint("Coordinates copied", 1200); - } - } catch { - showAprsHint("Copy failed", 1500); - } - })(); - }); +async function copyAprsCoords(text) { + try { + const clipboard = Reflect.get(navigator, "clipboard"); + if (!clipboard) return; + await clipboard.writeText(text); + showAprsHint("Coordinates copied", 1200); + } catch { + showAprsHint("Copy failed", 1500); } - return row; +} +function renderAprsRow(pkt, isFresh) { + return renderAprsPacketRow(pkt, { + fresh: isFresh, + distance: aprsDistanceText(pkt), + onMap: (lat, lon) => { + aprsWindow.navigateToAprsMap?.(lat, lon); + }, + onCopy: (text) => { + void copyAprsCoords(text); + } + }); } function renderAprsHistory() { pruneAprsPacketHistory(); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js deleted file mode 100644 index 30e308a1..00000000 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js +++ /dev/null @@ -1,156 +0,0 @@ -// src/plugins/aprs-shared.ts -function aprsPacketCategory(packet) { - const type = (packet.type ?? "").toLowerCase(); - const info = (packet.info ?? "").toLowerCase(); - if (packet.lat != null && packet.lon != null || type.includes("position")) return "position"; - if (type.includes("message") || info.startsWith(":")) return "message"; - if (type.includes("weather") || info.startsWith("_")) return "weather"; - if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry"; - return "other"; -} -function aprsCategoryLabel(category) { - switch (category) { - case "position": - return "Position"; - case "message": - return "Message"; - case "weather": - return "Weather"; - case "telemetry": - return "Telemetry"; - default: - return "Other"; - } -} -function aprsAgeText(timestampMs) { - if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now"; - const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3); - if (seconds < 5) return "just now"; - if (seconds < 60) return `${String(seconds)}s ago`; - const minutes = Math.round(seconds / 60); - if (minutes < 60) return `${String(minutes)}m ago`; - return `${String(Math.round(minutes / 60))}h ago`; -} -function aprsPacketSignature(packet) { - return [ - packet.srcCall ?? "", - packet.destCall ?? "", - packet.path ?? "", - packet.info ?? "", - packet.type ?? "", - packet.lat?.toFixed(4) ?? "", - packet.lon?.toFixed(4) ?? "" - ].join("|"); -} -function collapseAprsDuplicates(packets) { - const seen = /* @__PURE__ */ new Set(); - return packets.filter((packet) => { - const signature = aprsPacketSignature(packet); - if (seen.has(signature)) return false; - seen.add(signature); - return true; - }); -} -function aprsHexBytes(bytes) { - if (!bytes?.length) return "--"; - return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" "); -} -function renderAprsInfo(packet) { - if (packet.info_bytes?.length) { - return packet.info_bytes.map((byte) => renderAprsByte(byte)).join(""); - } - return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join(""); -} -function renderAprsByte(byte) { - return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; -} -function renderAprsCharacter(character) { - const code = character.charCodeAt(0); - return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; -} -function escapeAprsCharacter(character) { - if (character === "<") return "<"; - if (character === ">") return ">"; - if (character === "&") return "&"; - if (character === '"') return """; - return character; -} -var APRS_SPRITE_COLUMNS = 16; -var APRS_SPRITE_CELL_PX = 24; -var APRS_SPRITE_FIRST_CODE = 33; -var APRS_SPRITE_LAST_CODE = 126; -function aprsSpriteOffset(code) { - if (code.length !== 1) return null; - const point = code.charCodeAt(0); - if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null; - const index = point - APRS_SPRITE_FIRST_CODE; - const column = index % APRS_SPRITE_COLUMNS; - const row = Math.floor(index / APRS_SPRITE_COLUMNS); - return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`; -} -function aprsSymbolSprite(symbolTable, symbolCode) { - if (!symbolTable || !symbolCode) return null; - const symbolOffset = aprsSpriteOffset(symbolCode); - if (!symbolOffset) return null; - if (symbolTable === "/") { - return { - className: "aprs-symbol-primary", - backgroundPosition: symbolOffset, - label: `Primary APRS symbol ${symbolTable}${symbolCode}` - }; - } - if (symbolTable === "\\") { - return { - className: "aprs-symbol-alternate", - backgroundPosition: symbolOffset, - label: `Alternate APRS symbol ${symbolTable}${symbolCode}` - }; - } - const overlayOffset = aprsSpriteOffset(symbolTable); - if (!overlayOffset) return null; - return { - className: "aprs-symbol-overlaid", - backgroundPosition: `${overlayOffset}, ${symbolOffset}`, - label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}` - }; -} -function renderLocalAprsSymbol(packet, escapeHtml) { - if (!packet.symbolTable || !packet.symbolCode) return ""; - const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode); - if (sprite) { - return ``; - } - const symbol = escapeHtml(packet.symbolCode); - const table = packet.symbolTable === "/" ? "Primary" : "Alternate"; - return `${symbol}`; -} -function normalizeAprsPacket(packet, receiver) { - return { - rig_id: packet.rig_id || null, - receiver, - srcCall: packet.src_call ?? "", - destCall: packet.dest_call ?? "", - path: packet.path ?? "", - info: packet.info ?? "", - info_bytes: packet.info_bytes ?? [], - type: packet.packet_type ?? "", - crcOk: packet.crc_ok ?? false, - ts_ms: packet.ts_ms ?? null, - lat: packet.lat ?? null, - lon: packet.lon ?? null, - symbolTable: packet.symbol_table ?? null, - symbolCode: packet.symbol_code ?? null - }; -} - -export { - aprsPacketCategory, - aprsCategoryLabel, - aprsAgeText, - collapseAprsDuplicates, - aprsHexBytes, - renderAprsInfo, - aprsSymbolSprite, - renderLocalAprsSymbol, - normalizeAprsPacket -}; 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-YAWVT7YC.js new file mode 100644 index 00000000..3d8ea2e7 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-YAWVT7YC.js @@ -0,0 +1,251 @@ +// src/plugins/aprs-shared.ts +function escapeAprsHtml(value) { + return String(value ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """); +} +function aprsPacketCategory(packet) { + const type = (packet.type ?? "").toLowerCase(); + const info = (packet.info ?? "").toLowerCase(); + if (packet.lat != null && packet.lon != null || type.includes("position")) return "position"; + if (type.includes("message") || info.startsWith(":")) return "message"; + if (type.includes("weather") || info.startsWith("_")) return "weather"; + if (type.includes("telemetry") || info.startsWith("t#")) return "telemetry"; + return "other"; +} +function aprsCategoryLabel(category) { + switch (category) { + case "position": + return "Position"; + case "message": + return "Message"; + case "weather": + return "Weather"; + case "telemetry": + return "Telemetry"; + default: + return "Other"; + } +} +function aprsAgeText(timestampMs) { + if (typeof timestampMs !== "number" || !Number.isFinite(timestampMs)) return "just now"; + const seconds = Math.round(Math.max(0, Date.now() - timestampMs) / 1e3); + if (seconds < 5) return "just now"; + if (seconds < 60) return `${String(seconds)}s ago`; + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${String(minutes)}m ago`; + return `${String(Math.round(minutes / 60))}h ago`; +} +function aprsPacketSignature(packet) { + return [ + packet.srcCall ?? "", + packet.destCall ?? "", + packet.path ?? "", + packet.info ?? "", + packet.type ?? "", + packet.lat?.toFixed(4) ?? "", + packet.lon?.toFixed(4) ?? "" + ].join("|"); +} +function collapseAprsDuplicates(packets) { + const seen = /* @__PURE__ */ new Set(); + return packets.filter((packet) => { + const signature = aprsPacketSignature(packet); + if (seen.has(signature)) return false; + seen.add(signature); + return true; + }); +} +function aprsHexBytes(bytes) { + if (!bytes?.length) return "--"; + return bytes.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0")).join(" "); +} +function renderAprsInfo(packet) { + if (packet.info_bytes?.length) { + return packet.info_bytes.map((byte) => renderAprsByte(byte)).join(""); + } + return Array.from(packet.info ?? "", (character) => renderAprsCharacter(character)).join(""); +} +function renderAprsByte(byte) { + return byte >= 32 && byte <= 126 ? escapeAprsCharacter(String.fromCharCode(byte)) : `0x${byte.toString(16).toUpperCase().padStart(2, "0")}`; +} +function renderAprsCharacter(character) { + const code = character.charCodeAt(0); + return code >= 32 && code <= 126 ? escapeAprsCharacter(character) : `0x${code.toString(16).toUpperCase().padStart(2, "0")}`; +} +function escapeAprsCharacter(character) { + if (character === "<") return "<"; + if (character === ">") return ">"; + if (character === "&") return "&"; + if (character === '"') return """; + return character; +} +var APRS_SPRITE_COLUMNS = 16; +var APRS_SPRITE_CELL_PX = 24; +var APRS_SPRITE_FIRST_CODE = 33; +var APRS_SPRITE_LAST_CODE = 126; +function aprsSpriteOffset(code) { + if (code.length !== 1) return null; + const point = code.charCodeAt(0); + if (point < APRS_SPRITE_FIRST_CODE || point > APRS_SPRITE_LAST_CODE) return null; + const index = point - APRS_SPRITE_FIRST_CODE; + const column = index % APRS_SPRITE_COLUMNS; + const row = Math.floor(index / APRS_SPRITE_COLUMNS); + return `${String(-column * APRS_SPRITE_CELL_PX)}px ${String(-row * APRS_SPRITE_CELL_PX)}px`; +} +function aprsSymbolSprite(symbolTable, symbolCode) { + if (!symbolTable || !symbolCode) return null; + const symbolOffset = aprsSpriteOffset(symbolCode); + if (!symbolOffset) return null; + if (symbolTable === "/") { + return { + className: "aprs-symbol-primary", + backgroundPosition: symbolOffset, + label: `Primary APRS symbol ${symbolTable}${symbolCode}` + }; + } + if (symbolTable === "\\") { + return { + className: "aprs-symbol-alternate", + backgroundPosition: symbolOffset, + label: `Alternate APRS symbol ${symbolTable}${symbolCode}` + }; + } + const overlayOffset = aprsSpriteOffset(symbolTable); + if (!overlayOffset) return null; + return { + className: "aprs-symbol-overlaid", + backgroundPosition: `${overlayOffset}, ${symbolOffset}`, + label: `Alternate APRS symbol \\${symbolCode} with overlay ${symbolTable}` + }; +} +function renderLocalAprsSymbol(packet, escapeHtml) { + if (!packet.symbolTable || !packet.symbolCode) return ""; + const sprite = aprsSymbolSprite(packet.symbolTable, packet.symbolCode); + if (sprite) { + return ``; + } + const symbol = escapeHtml(packet.symbolCode); + const table = packet.symbolTable === "/" ? "Primary" : "Alternate"; + return `${symbol}`; +} +function normalizeAprsPacket(packet, receiver) { + return { + rig_id: packet.rig_id || null, + receiver, + srcCall: packet.src_call ?? "", + destCall: packet.dest_call ?? "", + path: packet.path ?? "", + info: packet.info ?? "", + info_bytes: packet.info_bytes ?? [], + type: packet.packet_type ?? "", + crcOk: packet.crc_ok ?? false, + ts_ms: packet.ts_ms ?? null, + lat: packet.lat ?? null, + lon: packet.lon ?? null, + symbolTable: packet.symbol_table ?? null, + symbolCode: packet.symbol_code ?? null + }; +} +function fahrenheitToCelsius(fahrenheit) { + return Math.round((fahrenheit - 32) * 5 / 9 * 10) / 10; +} +function summarizeAprsWeather(info) { + const parts = []; + const temperature = /t(-?\d{2,3})/.exec(info); + if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`); + const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info); + if (wind) { + const gust = /g(\d{3})/.exec(info); + const knots = Number(wind[2]); + parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`); + } + const humidity = /h(\d{2})/.exec(info); + if (humidity) { + const value = Number(humidity[1]); + parts.push(`${value === 0 ? 100 : value}% RH`); + } + const pressure = /b(\d{5})/.exec(info); + if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`); + const rain = /r(\d{3})/.exec(info); + if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`); + return parts.length ? parts.join(" · ") : null; +} +function summarizeAprsTelemetry(info) { + const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim()); + if (!match?.[2]) return null; + const channels = match[2].split(",").filter((value) => value.length > 0); + const bits = match[3] ? ` · bits ${match[3]}` : ""; + return `#${match[1]} · ${channels.join(" ")}${bits}`; +} +function summarizeAprsMessage(info) { + const match = /^:([^:]{9}):(.*)$/.exec(info); + if (!match?.[1] || match[2] == null) return null; + const addressee = match[1].trim(); + const text = match[2].replace(/\{\d+\s*$/, "").trim(); + return `→ ${addressee}: ${text}`; +} +function summarizeAprsCourseSpeed(info) { + const match = /(\d{3})\/(\d{3})/.exec(info); + if (!match) return null; + const knots = Number(match[2]); + if (knots === 0) return null; + return `${Number(match[1])}° ${knots} kt`; +} +function summarizeAprsPayload(packet) { + const info = packet.info ?? ""; + if (!info) return null; + const category = aprsPacketCategory(packet); + if (category === "message") return summarizeAprsMessage(info); + if (category === "weather") return summarizeAprsWeather(info); + if (category === "telemetry") return summarizeAprsTelemetry(info); + if (category === "position") { + const parts = []; + if (packet.lat != null && packet.lon != null) { + parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`); + } + const courseSpeed = summarizeAprsCourseSpeed(info); + if (courseSpeed) parts.push(courseSpeed); + const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim(); + if (comment && comment.length <= 60) parts.push(comment); + return parts.length ? parts.join(" · ") : null; + } + const text = info.replace(/^[>;${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(); + event.stopPropagation(); + const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number); + if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat, lon); + }); + }); + const copyButton = row.querySelector("[data-aprs-copy]"); + if (copyButton) { + copyButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton); + }); + } + return row; +} + +export { + aprsPacketCategory, + aprsAgeText, + collapseAprsDuplicates, + aprsSymbolSprite, + normalizeAprsPacket, + renderAprsPacketRow +}; 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 dc6edd25..8ac8bbed 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 @@ -1,13 +1,10 @@ import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol -} from "./chunk-ROTCLFXS.js"; + renderAprsPacketRow +} from "./chunk-YAWVT7YC.js"; import { hostCore, hostState @@ -15,7 +12,6 @@ import { // src/plugins/hf-aprs.ts var hfAprsWindow = window; -var escapeHfAprsHtml = (input) => hostCore.escapeMapHtml(input); var hfAprsStatus = document.getElementById("hf-aprs-status"); var hfAprsPacketsEl = document.getElementById("hf-aprs-packets"); var hfAprsFilterInput = document.getElementById("hf-aprs-filter"); @@ -102,51 +98,27 @@ function updateHfAprsChipState() { hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup); } function renderHfAprsRow(pkt, isFresh) { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - const ts = pkt._ts || (/* @__PURE__ */ new Date()).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeHfAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const hfBadge = 'HF'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` : ""; - const distance = hfAprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - row.innerHTML = `
${ts}` + hfBadge + symbolHtml + `${escapeHfAprsHtml(pkt.srcCall ?? "")}>${escapeHfAprsHtml(pkt.destCall || "")}${escapeHfAprsHtml(categoryLabel)}` + pathBadge + crcBadge + `
${escapeHfAprsHtml(age)}` + (distance ? `${escapeHfAprsHtml(distance)}` : "") + `${escapeHfAprsHtml(pkt.type || "--")}
${renderAprsInfo(pkt)}` + (posLink ? `${posLink}` : "") + `
` + (pkt.lat != null && pkt.lon != null ? `` : "") + (pkt.lat != null && pkt.lon != null ? `` : "") + `QRZ
Details
Source${escapeHfAprsHtml(pkt.srcCall || "--")}Destination${escapeHfAprsHtml(pkt.destCall || "--")}Type${escapeHfAprsHtml(pkt.type || "--")}Path${escapeHfAprsHtml(pkt.path || "--")}Age${escapeHfAprsHtml(age)}CRC${pkt.crcOk ? "OK" : "Failed"}Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}Info${escapeHfAprsHtml(pkt.info || "--")}Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}
`; - row.querySelectorAll("[data-aprs-map]").forEach((el) => { - el.addEventListener("click", (evt) => { - evt.preventDefault(); - const raw = el.dataset.aprsMap ?? ""; - const [lat, lon] = raw.split(",").map(Number); - if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { - hfAprsWindow.navigateToAprsMap(lat, lon); - } - }); + return renderAprsPacketRow(pkt, { + fresh: isFresh, + badge: "HF", + distance: hfAprsDistanceText(pkt), + onMap: (lat, lon) => { + hfAprsWindow.navigateToAprsMap?.(lat, lon); + }, + onCopy: (text) => { + void copyHfAprsCoords(text); + } }); - const copyBtn = row.querySelector("[data-aprs-copy]"); - if (copyBtn) { - copyBtn.addEventListener("click", () => { - void (async () => { - const raw = copyBtn.dataset.aprsCopy ?? ""; - try { - const clipboard = Reflect.get(navigator, "clipboard"); - if (clipboard) { - await clipboard.writeText(raw); - hostCore.showHint("Coordinates copied", 1200); - } - } catch { - hostCore.showHint("Copy failed", 1500); - } - })(); - }); +} +async function copyHfAprsCoords(text) { + try { + const clipboard = Reflect.get(navigator, "clipboard"); + if (!clipboard) return; + await clipboard.writeText(text); + hostCore.showHint("Coordinates copied", 1200); + } catch { + hostCore.showHint("Copy failed", 1500); } - return row; } function renderHfAprsHistory() { pruneHfAprsPacketHistory(); 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 ca02c8cb..759ebf1b 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-ROTCLFXS.js"; +} from "./chunk-YAWVT7YC.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 977cd9a6..600d963e 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 @@ -722,20 +722,6 @@ SPDX-License-Identifier: GPL-2.0-or-later Waiting for server decode -
-
- Frames - 0 total -
-
- Visible - 0 shown -
-
- Latest - No packets yet -
-
@@ -743,11 +729,15 @@ SPDX-License-Identifier: GPL-2.0-or-later -
-
+ + + 0 shown + 0 total + No packets yet +
@@ -757,20 +747,6 @@ SPDX-License-Identifier: GPL-2.0-or-later Waiting for server decode -
-
- Frames - 0 total -
-
- Visible - 0 shown -
-
- Latest - No packets yet -
-
@@ -778,11 +754,15 @@ SPDX-License-Identifier: GPL-2.0-or-later -
-
+ + + 0 shown + 0 total + No packets yet +
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 adb8716f..7cda2aac 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,34 +2844,93 @@ body.map-fake-fullscreen-active { 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.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; } +/* One line per frame, opening in place. 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 { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.28rem 0.55rem; + cursor: pointer; + list-style: none; + white-space: nowrap; +} +.aprs-line::-webkit-details-marker { display: none; } +.aprs-line:hover { + background: color-mix(in srgb, var(--btn-bg) 45%, transparent); +} +.aprs-packet[open] > .aprs-line { + background: color-mix(in srgb, var(--accent-green) 8%, transparent); +} +/* 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 { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + color: var(--text); +} +.aprs-line-distance { + flex: 0 0 auto; + color: var(--text-muted); + font-size: 0.75rem; +} +.aprs-expanded { + display: flex; + flex-direction: column; + gap: 0.35rem; + padding: 0.1rem 0.55rem 0.55rem 2.6rem; +} +.aprs-expanded-meta { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + font-size: 0.75rem; + color: var(--text-muted); +} +.aprs-expanded-raw, +.aprs-expanded-bytes { + word-break: break-all; + font-size: 0.78rem; + color: var(--text); +} +.aprs-expanded-bytes { + color: var(--text-muted); + font-size: 0.72rem; +} .aprs-packet-new { animation: aprs-row-flash 1.2s ease; } .aprs-packet-crc { opacity: 0.6; } -.aprs-row-head, -.aprs-row-meta, -.aprs-row-detail, .aprs-row-actions { display: flex; align-items: center; gap: 0.45rem; flex-wrap: wrap; } -.aprs-row-head + .aprs-row-meta, -.aprs-row-meta + .aprs-row-detail, -.aprs-row-detail + .aprs-row-actions { - margin-top: 0.2rem; -} -.aprs-row-detail { +/* Counts sit with the filters rather than in three cards of their own: they + are one short line of text, and they were taking a fifth of the panel. */ +.aprs-counts { + display: inline-flex; + align-items: center; + gap: 0.6rem; + margin-left: auto; + font-size: 0.72rem; color: var(--text-muted); - font-size: 0.78rem; + white-space: nowrap; } -.aprs-row-actions { - margin-top: 0.28rem; +.aprs-filter-sep { + width: 1px; + align-self: stretch; + min-height: 1.2rem; + margin-inline: 0.15rem; + background: color-mix(in srgb, var(--border-light) 65%, transparent); } .aprs-badge { display: inline-flex; 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 e9efafce..cf7c00d2 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 @@ -30,6 +30,14 @@ export interface AprsPacket { symbol_code?: string | null; } +function escapeAprsHtml(value: string): string { + return String(value ?? "") + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """); +} + export function aprsPacketCategory(packet: AprsPacket): AprsCategory { const type = (packet.type ?? "").toLowerCase(); const info = (packet.info ?? "").toLowerCase(); @@ -205,3 +213,174 @@ export function normalizeAprsPacket(packet: AprsPacket, receiver: unknown): Aprs symbolCode: packet.symbol_code ?? null, }; } + +// ── Payload summaries ────────────────────────────────────────────────────── +// APRS packs its meaning into the information field with a set of one-character +// type identifiers and fixed-width encodings (APRS 1.0.1, chapters 6-15). The +// list showed that field as it arrives on the air, so reading a weather report +// meant decoding "_10090556c220s004g005t077..." by eye. These produce a line +// of plain text for the common types and leave the raw field to the expanded +// view, which is still the authority when a summary cannot be made. + +/** `t077` → 25.0 °C. APRS carries temperature in whole degrees Fahrenheit. */ +function fahrenheitToCelsius(fahrenheit: number): number { + return Math.round(((fahrenheit - 32) * 5 / 9) * 10) / 10; +} + +/** Weather report fields: wind, gust, temperature, rain, humidity, pressure. */ +function summarizeAprsWeather(info: string): string | null { + const parts: string[] = []; + const temperature = /t(-?\d{2,3})/.exec(info); + if (temperature) parts.push(`${fahrenheitToCelsius(Number(temperature[1]))} °C`); + const wind = /(\d{3})\/(\d{3})/.exec(info) ?? /c(\d{3}).*?s(\d{3})/.exec(info); + if (wind) { + const gust = /g(\d{3})/.exec(info); + const knots = Number(wind[2]); + parts.push(`wind ${Number(wind[1])}° ${knots} kt${gust ? ` gust ${Number(gust[1])}` : ""}`); + } + const humidity = /h(\d{2})/.exec(info); + if (humidity) { + const value = Number(humidity[1]); + parts.push(`${value === 0 ? 100 : value}% RH`); + } + const pressure = /b(\d{5})/.exec(info); + if (pressure) parts.push(`${(Number(pressure[1]) / 10).toFixed(1)} hPa`); + const rain = /r(\d{3})/.exec(info); + if (rain && Number(rain[1]) > 0) parts.push(`rain ${(Number(rain[1]) / 100).toFixed(2)}"`); + return parts.length ? parts.join(" · ") : null; +} + +/** `T#005,199,000,255,073,123,01101001` → sequence, five channels, eight bits. */ +function summarizeAprsTelemetry(info: string): string | null { + const match = /^T#(\d+|MIC)((?:,-?[\d.]*)+)(?:,([01]{8}))?/.exec(info.trim()); + if (!match?.[2]) return null; + const channels = match[2].split(",").filter((value) => value.length > 0); + const bits = match[3] ? ` · bits ${match[3]}` : ""; + return `#${match[1]} · ${channels.join(" ")}${bits}`; +} + +/** `:DEST :text{01` → addressed message text. */ +function summarizeAprsMessage(info: string): string | null { + const match = /^:([^:]{9}):(.*)$/.exec(info); + if (!match?.[1] || match[2] == null) return null; + const addressee = match[1].trim(); + const text = match[2].replace(/\{\d+\s*$/, "").trim(); + return `→ ${addressee}: ${text}`; +} + +/** Course/speed appended to a position, as `088/036`. */ +function summarizeAprsCourseSpeed(info: string): string | null { + const match = /(\d{3})\/(\d{3})/.exec(info); + if (!match) return null; + const knots = Number(match[2]); + if (knots === 0) return null; + return `${Number(match[1])}° ${knots} kt`; +} + +/** Whatever a frame is worth saying in one line, or null to fall back to raw. */ +export function summarizeAprsPayload(packet: AprsPacket): string | null { + const info = packet.info ?? ""; + if (!info) return null; + const category = aprsPacketCategory(packet); + if (category === "message") return summarizeAprsMessage(info); + if (category === "weather") return summarizeAprsWeather(info); + if (category === "telemetry") return summarizeAprsTelemetry(info); + if (category === "position") { + const parts: string[] = []; + if (packet.lat != null && packet.lon != null) { + parts.push(`${packet.lat.toFixed(4)}, ${packet.lon.toFixed(4)}`); + } + const courseSpeed = summarizeAprsCourseSpeed(info); + if (courseSpeed) parts.push(courseSpeed); + // Whatever the station wrote after the position report. + const comment = info.replace(/^[!=@/][^>]*[>_]?/, "").replace(/\d{3}\/\d{3}/, "").trim(); + if (comment && comment.length <= 60) parts.push(comment); + return parts.length ? parts.join(" · ") : null; + } + // Status and anything else: the text it carries, minus its type character. + const text = info.replace(/^[>; void; + /** Puts a frame's coordinates on the clipboard. */ + onCopy?: (text: string, button: HTMLElement) => void; +} + +export function renderAprsPacketRow(packet: AprsPacket, options: AprsRowOptions = {}): HTMLElement { + const row = document.createElement("details"); + row.className = "aprs-packet"; + if (!packet.crcOk) row.classList.add("aprs-packet-crc"); + if (options.fresh) row.classList.add("aprs-packet-new"); + + const time = packet._ts + || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); + const category = aprsPacketCategory(packet); + 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.querySelectorAll("[data-aprs-map]").forEach((element) => { + element.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + const [lat, lon] = (element.dataset.aprsMap ?? "").split(",").map(Number); + if (Number.isFinite(lat) && Number.isFinite(lon)) options.onMap?.(lat as number, lon as number); + }); + }); + const copyButton = row.querySelector("[data-aprs-copy]"); + if (copyButton) { + copyButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + options.onCopy?.(copyButton.dataset.aprsCopy ?? "", copyButton); + }); + } + return row; +} diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts index b306cbc3..45e55674 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/aprs.ts @@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js"; import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol, + renderAprsPacketRow, type AprsPacket, type AprsTypeFilter, } from "./aprs-shared"; @@ -141,93 +138,24 @@ function updateAprsChipState() { aprsCollapseDupBtn?.classList.toggle("active", aprsCollapseDup); } -function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - - const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null - ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` - : ""; - const distance = aprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - - row.innerHTML = - `
` + - `${ts}` + - symbolHtml + - `${escapeAprsHtml(pkt.srcCall ?? "")}` + - `>${escapeAprsHtml(pkt.destCall || "")}` + - `${escapeAprsHtml(categoryLabel)}` + - pathBadge + - crcBadge + - `
` + - `
` + - `${escapeAprsHtml(age)}` + - (distance ? `${escapeAprsHtml(distance)}` : "") + - `${escapeAprsHtml(pkt.type || "--")}` + - `
` + - `
` + - `${renderAprsInfo(pkt)}` + - (posLink ? `${posLink}` : "") + - `
` + - `
` + - (pkt.lat != null && pkt.lon != null ? `` : "") + - (pkt.lat != null && pkt.lon != null ? `` : "") + - `QRZ` + - `
` + - `
` + - `Details` + - `
` + - `Source${escapeAprsHtml(pkt.srcCall || "--")}` + - `Destination${escapeAprsHtml(pkt.destCall || "--")}` + - `Type${escapeAprsHtml(pkt.type || "--")}` + - `Path${escapeAprsHtml(pkt.path || "--")}` + - `Age${escapeAprsHtml(age)}` + - `CRC${pkt.crcOk ? "OK" : "Failed"}` + - `Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}` + - `Info${escapeAprsHtml(pkt.info || "--")}` + - `Info Bytes${escapeAprsHtml(aprsHexBytes(pkt.info_bytes))}` + - `
` + - `
`; - - row.querySelectorAll("[data-aprs-map]").forEach((el) => { - el.addEventListener("click", (evt) => { - evt.preventDefault(); - const raw = el.dataset.aprsMap ?? ""; - const [lat, lon] = raw.split(",").map(Number); - if (aprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { - aprsWindow.navigateToAprsMap(lat, lon); - } - }); - }); - - const copyBtn = row.querySelector("[data-aprs-copy]"); - if (copyBtn) { - copyBtn.addEventListener("click", () => { void (async () => { - const raw = copyBtn.dataset.aprsCopy ?? ""; - try { - const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; - if (clipboard) { - await clipboard.writeText(raw); - showAprsHint("Coordinates copied", 1200); - } - } catch { - showAprsHint("Copy failed", 1500); - } - })(); }); +async function copyAprsCoords(text: string): Promise { + try { + const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; + if (!clipboard) return; + await clipboard.writeText(text); + showAprsHint("Coordinates copied", 1200); + } catch { + showAprsHint("Copy failed", 1500); } +} - return row; +function renderAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { + return renderAprsPacketRow(pkt, { + fresh: isFresh, + distance: aprsDistanceText(pkt), + onMap: (lat, lon) => { aprsWindow.navigateToAprsMap?.(lat, lon); }, + onCopy: (text) => { void copyAprsCoords(text); }, + }); } function renderAprsHistory() { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts index 3ee88258..3192fe2e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/plugins/hf-aprs.ts @@ -6,13 +6,10 @@ import { hostCore, hostState } from "./host.js"; import { aprsAgeText, - aprsCategoryLabel, - aprsHexBytes, aprsPacketCategory, collapseAprsDuplicates, normalizeAprsPacket, - renderAprsInfo, - renderLocalAprsSymbol, + renderAprsPacketRow, type AprsPacket, type AprsTypeFilter, } from "./aprs-shared"; @@ -27,7 +24,6 @@ interface HfAprsBridge { trxUi: { confirm(options: { title: string; message: string; confirmLabel: string }): Promise }; } const hfAprsWindow = window as unknown as HfAprsBridge; -const escapeHfAprsHtml = (input: string): string => hostCore.escapeMapHtml(input); // --- HF APRS Decoder Plugin (server-side decode, 300 baud) --- const hfAprsStatus = document.getElementById("hf-aprs-status"); @@ -128,95 +124,27 @@ function updateHfAprsChipState() { hfAprsCollapseDupBtn?.classList.toggle("active", hfAprsCollapseDup); } +// HF traffic goes in the same row as VHF, marked with the band it came in on. +// This was a second copy of the same forty lines of markup. function renderHfAprsRow(pkt: AprsPacket, isFresh: boolean): HTMLElement { - const row = document.createElement("div"); - row.className = "aprs-packet"; - if (!pkt.crcOk) row.classList.add("aprs-packet-crc"); - if (isFresh) row.classList.add("aprs-packet-new"); - - const ts = pkt._ts || new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); - const age = aprsAgeText(pkt._tsMs); - const category = aprsPacketCategory(pkt); - const categoryLabel = aprsCategoryLabel(category); - const categoryClass = `aprs-badge aprs-badge-type aprs-badge-type-${category}`; - const pathBadge = pkt.path ? `${escapeHfAprsHtml(pkt.path)}` : ""; - const crcBadge = pkt.crcOk ? "" : 'CRC Fail'; - const hfBadge = 'HF'; - const symbolHtml = renderLocalAprsSymbol(pkt, escapeHfAprsHtml); - const posLink = pkt.lat != null && pkt.lon != null - ? `${pkt.lat.toFixed(4)}, ${pkt.lon.toFixed(4)}` - : ""; - const distance = hfAprsDistanceText(pkt); - const qrzHref = `https://qrzcq.com/call/${encodeURIComponent(pkt.srcCall || "")}`; - - row.innerHTML = - `
` + - `${ts}` + - hfBadge + - symbolHtml + - `${escapeHfAprsHtml(pkt.srcCall ?? "")}` + - `>${escapeHfAprsHtml(pkt.destCall || "")}` + - `${escapeHfAprsHtml(categoryLabel)}` + - pathBadge + - crcBadge + - `
` + - `
` + - `${escapeHfAprsHtml(age)}` + - (distance ? `${escapeHfAprsHtml(distance)}` : "") + - `${escapeHfAprsHtml(pkt.type || "--")}` + - `
` + - `
` + - `${renderAprsInfo(pkt)}` + - (posLink ? `${posLink}` : "") + - `
` + - `
` + - (pkt.lat != null && pkt.lon != null ? `` : "") + - (pkt.lat != null && pkt.lon != null ? `` : "") + - `QRZ` + - `
` + - `
` + - `Details` + - `
` + - `Source${escapeHfAprsHtml(pkt.srcCall || "--")}` + - `Destination${escapeHfAprsHtml(pkt.destCall || "--")}` + - `Type${escapeHfAprsHtml(pkt.type || "--")}` + - `Path${escapeHfAprsHtml(pkt.path || "--")}` + - `Age${escapeHfAprsHtml(age)}` + - `CRC${pkt.crcOk ? "OK" : "Failed"}` + - `Position${pkt.lat != null && pkt.lon != null ? `${pkt.lat.toFixed(5)}, ${pkt.lon.toFixed(5)}` : "--"}` + - `Info${escapeHfAprsHtml(pkt.info || "--")}` + - `Info Bytes${escapeHfAprsHtml(aprsHexBytes(pkt.info_bytes))}` + - `
` + - `
`; - - row.querySelectorAll("[data-aprs-map]").forEach((el) => { - el.addEventListener("click", (evt) => { - evt.preventDefault(); - const raw = el.dataset.aprsMap ?? ""; - const [lat, lon] = raw.split(",").map(Number); - if (hfAprsWindow.navigateToAprsMap && typeof lat === "number" && typeof lon === "number" && Number.isFinite(lat) && Number.isFinite(lon)) { - hfAprsWindow.navigateToAprsMap(lat, lon); - } - }); + return renderAprsPacketRow(pkt, { + fresh: isFresh, + badge: "HF", + distance: hfAprsDistanceText(pkt), + onMap: (lat: number, lon: number) => { hfAprsWindow.navigateToAprsMap?.(lat, lon); }, + onCopy: (text: string) => { void copyHfAprsCoords(text); }, }); +} - const copyBtn = row.querySelector("[data-aprs-copy]"); - if (copyBtn) { - copyBtn.addEventListener("click", () => { void (async () => { - const raw = copyBtn.dataset.aprsCopy ?? ""; - try { - const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; - if (clipboard) { - await clipboard.writeText(raw); - hostCore.showHint("Coordinates copied", 1200); - } - } catch { - hostCore.showHint("Copy failed", 1500); - } - })(); }); +async function copyHfAprsCoords(text: string): Promise { + try { + const clipboard = Reflect.get(navigator, "clipboard") as Clipboard | undefined; + if (!clipboard) return; + await clipboard.writeText(text); + hostCore.showHint("Coordinates copied", 1200); + } catch { + hostCore.showHint("Copy failed", 1500); } - - return row; } function renderHfAprsHistory() { 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 1dd8ed14..ee21064b 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 @@ -164,3 +164,71 @@ try { await replay.browser.close(); await historyFixture.close(); } + +// The APRS list: one line per frame, with the information field read for the +// operator rather than shown as it arrives on the air. +const APRS_FRAMES = [ + { packet_type: "weather", info: "_10090556c220s004g005t077r000p000P000h50b09900" }, + { packet_type: "message", info: ":SP2SJG-9 :Hello from the field{01" }, + { packet_type: "telemetry", info: "T#005,199,000,255,073,123,01101001" }, + { packet_type: "position", info: "!5421.30N/01839.20E>Test beacon 73", lat: 54.35, lon: 18.65 }, +].map((frame, index) => ({ + src_call: `SP2SJG-${index}`, dest_call: "APRS", path: "WIDE1-1", crc_ok: true, + symbol_table: "/", symbol_code: ">", rig_id: "rig-a", ts_ms: Date.now() - index * 1000, + ...frame, +})); + +const aprsFixture = await startWebFixture({ spectrum: true, history: { aprs: APRS_FRAMES } }); +const aprs = await startBrowser(chromium); + +try { + await aprs.page.setViewportSize({ width: 1400, height: 900 }); + await aprs.page.goto(`${aprsFixture.origin}/digital-modes`, { waitUntil: "domcontentloaded" }); + await aprs.page.locator("#tab-digital-modes").waitFor({ state: "visible" }); + await aprs.page.waitForTimeout(2000); + await aprs.page.locator('.sub-tab[data-subtab="aprs"]').click(); + await aprs.page.waitForTimeout(400); + + const rows = await aprs.page.evaluate(() => + [...document.querySelectorAll("#aprs-packets .aprs-packet")].map((row) => ({ + 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() ?? "", + }))); + // Newest first, so find each by the type it carries rather than by position. + const summaryOf = (type) => rows.find((row) => row.type === type)?.summary ?? ""; + assert.equal(rows.length, APRS_FRAMES.length, `rendered ${rows.length} frames`); + for (const row of rows) { + assert.equal(row.tag, "DETAILS", "a frame is not expandable in place"); + assert.ok(row.height < 44, `a frame is ${row.height}px tall; it used to be a card of about 140`); + } + // Each payload read rather than echoed: 25 °C from t077, the addressee from + // a message, the sequence from telemetry, the fix from a position report. + assert.match(summaryOf("Weather"), /25 °C/, `weather summary: ${summaryOf("Weather")}`); + assert.match(summaryOf("Weather"), /990\.0 hPa/, `weather summary: ${summaryOf("Weather")}`); + assert.match(summaryOf("Message"), /→ SP2SJG-9: Hello from the field/, `message summary: ${summaryOf("Message")}`); + assert.match(summaryOf("Telemetry"), /^#005/, `telemetry summary: ${summaryOf("Telemetry")}`); + assert.match(summaryOf("Position"), /54\.3500, 18\.6500/, `position summary: ${summaryOf("Position")}`); + + // 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(); + 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 ?? "", + }; + }); + 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}`); + + assert.deepEqual(aprs.runtimeErrors, []); +} finally { + await aprs.browser.close(); + await aprsFixture.close(); +}