From 5b7dd493d42820c3287fbcb161c5c2a59348953c Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Mon, 3 Aug 2026 19:36:53 +0200 Subject: [PATCH] [feat](trx-frontend-http): draw APRS symbols from the sprite sheets Resolve a table/code pair to a sprite cell in aprs-shared, and use it from both the packet lists and the map markers, which had each been printing the raw symbol character in a bordered box. A table identifier of / or \ selects the primary or alternate sheet directly. Anything else is an overlay character, which the APRS spec draws on top of the alternate symbol -- so those stack the overlay sheet over the alternate one rather than picking a sheet. Codes outside 0x21..0x7E have no cell and keep the old character box. The sheet URLs stay in the stylesheet so a min-resolution query can swap in the retina sheets; only the cell offset is computed and set inline. Map markers share the helper through the plugin chunk, so the map stays free of any remote symbol fetch. Verified in a browser against the real stylesheet and sheets: /> is a car, /_ a WX circle, /& an igate diamond, \n a red triangle, and the overlays S> and 7# carry their character on the alternate symbol. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA Signed-off-by: Stan Grams --- .../assets/web/generated/aprs.js | 2 +- .../{chunk-M2I6DH4X.js => chunk-ROTCLFXS.js} | 44 ++++++++++ .../assets/web/generated/hf-aprs.js | 2 +- .../assets/web/generated/map-core.js | 9 +- .../trx-frontend-http/assets/web/style.css | 16 +++- .../frontend/src/map-core.ts | 10 ++- .../frontend/src/plugins/aprs-shared.ts | 72 ++++++++++++++++ .../frontend/tests/aprs-symbols.test.mjs | 85 +++++++++++++++++++ .../frontend/tests/bundle-entry.mjs | 3 +- 9 files changed, 235 insertions(+), 8 deletions(-) rename src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/{chunk-M2I6DH4X.js => chunk-ROTCLFXS.js} (68%) create mode 100644 src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs-symbols.test.mjs 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 a0ccd2a7..fd3f223e 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 @@ -7,7 +7,7 @@ import { normalizeAprsPacket, renderAprsInfo, renderLocalAprsSymbol -} from "./chunk-M2I6DH4X.js"; +} from "./chunk-ROTCLFXS.js"; import { hostCore, hostState diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js similarity index 68% rename from src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js rename to src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js index 1d386a5f..30e308a1 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-M2I6DH4X.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/chunk-ROTCLFXS.js @@ -75,8 +75,51 @@ function escapeAprsCharacter(character) { 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}`; @@ -107,6 +150,7 @@ export { collapseAprsDuplicates, aprsHexBytes, renderAprsInfo, + aprsSymbolSprite, renderLocalAprsSymbol, normalizeAprsPacket }; 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 6b6d80a7..dc6edd25 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 @@ -7,7 +7,7 @@ import { normalizeAprsPacket, renderAprsInfo, renderLocalAprsSymbol -} from "./chunk-M2I6DH4X.js"; +} from "./chunk-ROTCLFXS.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 5cff0e48..a8755ecb 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,3 +1,7 @@ +import { + aprsSymbolSprite +} from "./chunk-ROTCLFXS.js"; + // src/map-core.ts function mapEl(id) { const element = document.querySelector(`#${CSS.escape(id)}`); @@ -1660,10 +1664,11 @@ var mapWindow = window; } function aprsSymbolIcon(symbolTable, symbolCode) { if (!symbolTable || !symbolCode) return null; - const table = symbolTable === "/" ? "primary" : "alternate"; + const sprite = aprsSymbolSprite(symbolTable, symbolCode); + const html = sprite ? `` : `
${escapeMapHtml(symbolCode)}
`; return L.divIcon({ className: "", - html: `
${escapeMapHtml(symbolCode)}
`, + html, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -12] 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 960987fa..3d38895c 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 @@ -2533,7 +2533,21 @@ body.map-fake-fullscreen-active { font-size: 0.75rem; word-break: break-all; } -.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-size: 384px 192px; vertical-align: middle; margin-right: 0.3rem; } +/* APRS symbols are drawn from the vendored hessu/aprs-symbols sprite sheets: + 16x6 grids of 24px cells, indexed by `symbol code - 0x21`. The cell offset + is set inline by the APRS/map bundles; the sheet URL stays here so retina + displays can swap in the @2x variants. */ +.aprs-symbol { display: inline-block; width: 24px; height: 24px; background-repeat: no-repeat; background-size: 384px 144px; vertical-align: middle; margin-right: 0.3rem; } +.aprs-symbol-marker { margin-right: 0; } +.aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0.png'); } +.aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1.png'); } +.aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2.png'), url('/vendor/aprs-symbols-24-1.png'); } +@media (min-resolution: 2dppx) { + .aprs-symbol-primary { background-image: url('/vendor/aprs-symbols-24-0-2x.png'); } + .aprs-symbol-alternate { background-image: url('/vendor/aprs-symbols-24-1-2x.png'); } + .aprs-symbol-overlaid { background-image: url('/vendor/aprs-symbols-24-2-2x.png'), url('/vendor/aprs-symbols-24-1-2x.png'); } +} +/* Fallback for symbol codes outside the sprite sheets: show the raw character. */ .aprs-symbol-local { border: 1px solid var(--border); border-radius: 4px; background: var(--surface-raised); color: var(--text); font: 700 0.8rem/22px ui-monospace, monospace; text-align: center; } .aprs-pos { color: var(--accent-green); text-decoration: none; margin-left: 0.3rem; font-size: 0.8rem; } .aprs-pos:hover { text-decoration: underline; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts index a6b896b3..d9040c84 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/map-core.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-2.0-or-later import type * as Leaflet from "leaflet"; +import { aprsSymbolSprite } from "./plugins/aprs-shared"; export {}; @@ -2002,10 +2003,15 @@ const mapWindow = window as unknown as MapWindow; function aprsSymbolIcon(symbolTable: string, symbolCode: string): Leaflet.DivIcon | null { if (!symbolTable || !symbolCode) return null; - const table = symbolTable === "/" ? "primary" : "alternate"; + const sprite = aprsSymbolSprite(symbolTable, symbolCode); + const html = sprite + ? `` + : `
${escapeMapHtml(symbolCode)}
`; return L.divIcon({ className: "", - html: `
${escapeMapHtml(symbolCode)}
`, + html, iconSize: [24, 24], iconAnchor: [12, 12], popupAnchor: [0, -12] 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 df91abca..e9efafce 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 @@ -108,8 +108,80 @@ function escapeAprsCharacter(character: string): string { return character; } +// The vendored sprite sheets (assets/web/vendor/aprs-symbols-24-*.png) are +// 16x6 grids of 24px cells covering the printable codes 0x21..0x7E, so a +// symbol's cell index is simply `code - 0x21`. The sheet URLs live in CSS so +// the retina variants can be picked up by a media query; only the cell offsets +// are computed here. +const APRS_SPRITE_COLUMNS = 16; +const APRS_SPRITE_CELL_PX = 24; +const APRS_SPRITE_FIRST_CODE = 0x21; +const APRS_SPRITE_LAST_CODE = 0x7e; + +export interface AprsSymbolSprite { + /** Sheet modifier class appended to `.aprs-symbol`. */ + className: string; + /** `background-position` covering the overlay layer first, if any. */ + backgroundPosition: string; + /** Human-readable description for the tooltip. */ + label: string; +} + +function aprsSpriteOffset(code: string): string | null { + 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`; +} + +/** + * Resolve an APRS table/code pair to a sprite cell. A table identifier of `/` + * selects the primary sheet and `\` the alternate one; any other character is + * an overlay, which draws that character from the overlay sheet on top of the + * alternate symbol. Returns null when the pair is outside the sprite sheets, + * leaving callers to fall back to the raw character. + */ +export function aprsSymbolSprite( + symbolTable: string | null | undefined, + symbolCode: string | null | undefined, +): AprsSymbolSprite | null { + 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}`, + }; +} + export function renderLocalAprsSymbol(packet: AprsPacket, escapeHtml: (value: string) => string): string { 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}`; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs-symbols.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs-symbols.test.mjs new file mode 100644 index 00000000..541592e8 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/aprs-symbols.test.mjs @@ -0,0 +1,85 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +import assert from "node:assert/strict"; +import test from "node:test"; +import vm from "node:vm"; +import { readFile } from "node:fs/promises"; +import { bundleEntry } from "./bundle-entry.mjs"; + +const sharedUrl = new URL("../src/plugins/aprs-shared.ts", import.meta.url); +const stylePath = new URL("../../assets/web/style.css", import.meta.url); + +async function loadShared() { + const source = await bundleEntry(sharedUrl, "trxAprsShared"); + const context = vm.createContext({ Math, String, Number, Array, Date, Set, console }); + new vm.Script(source).runInContext(context); + return context.trxAprsShared; +} + +// The sheets are 16x6 grids of 24px cells covering 0x21..0x7E, so cell index +// is `code - 0x21`. Getting this wrong shifts every station to a neighbouring +// icon, which is invisible in a screenshot but wrong on every packet. +test("sprite cells are indexed from the first printable symbol code", async () => { + const { aprsSymbolSprite } = await loadShared(); + + const first = aprsSymbolSprite("/", "!"); + assert.equal(first.className, "aprs-symbol-primary"); + assert.equal(first.backgroundPosition, "0px 0px"); + assert.equal(first.label, "Primary APRS symbol /!"); + // '>' is 0x3E -> index 29 -> column 13, row 1. + assert.equal(aprsSymbolSprite("/", ">").backgroundPosition, "-312px -24px"); + // '~' is 0x7E -> index 93 -> the last cell of the last row. + assert.equal(aprsSymbolSprite("/", "~").backgroundPosition, "-312px -120px"); +}); + +test("the table identifier selects the primary, alternate, or overlay sheet", async () => { + const { aprsSymbolSprite } = await loadShared(); + + assert.equal(aprsSymbolSprite("/", "_").className, "aprs-symbol-primary"); + assert.equal(aprsSymbolSprite("\\", "_").className, "aprs-symbol-alternate"); + + // An alphanumeric table identifier is an overlay character drawn on top of + // the alternate symbol: overlay cell first, then the symbol cell. + const overlaid = aprsSymbolSprite("S", ">"); + assert.equal(overlaid.className, "aprs-symbol-overlaid"); + assert.equal(overlaid.backgroundPosition, "-48px -72px, -312px -24px"); + assert.equal(overlaid.label, "Alternate APRS symbol \\> with overlay S"); +}); + +test("codes outside the sprite sheets fall back to the raw character", async () => { + const { aprsSymbolSprite, renderLocalAprsSymbol } = await loadShared(); + const escape = (value) => value; + + assert.equal(aprsSymbolSprite("/", " "), null); + assert.equal(aprsSymbolSprite("/", ""), null); + assert.equal(aprsSymbolSprite("/", "ab"), null); + assert.equal(aprsSymbolSprite(null, ">"), null); + + assert.equal(renderLocalAprsSymbol({ symbolTable: "/", symbolCode: " " }, escape), + ' '); + assert.equal(renderLocalAprsSymbol({}, escape), ""); +}); + +test("rendered symbols carry a sprite class and an inline cell offset", async () => { + const { renderLocalAprsSymbol } = await loadShared(); + const html = renderLocalAprsSymbol({ symbolTable: "/", symbolCode: ">" }, (value) => value); + + assert.match(html, /class="aprs-symbol aprs-symbol-primary"/); + assert.match(html, /style="background-position:-312px -24px"/); + assert.match(html, /aria-label="Primary APRS symbol \/>"/); + assert.equal(html.includes("http"), false); +}); + +// The cell offsets are computed in the bundles, so the sheet URLs and the grid +// geometry have to stay in lockstep with them here. +test("the stylesheet serves every sheet locally at the sprite geometry", async () => { + const css = await readFile(stylePath, "utf8"); + + assert.match(css, /\.aprs-symbol\s*\{[^}]*background-size:\s*384px 144px/); + for (const sheet of ["24-0", "24-1", "24-2", "24-0-2x", "24-1-2x", "24-2-2x"]) { + assert.ok(css.includes(`url('/vendor/aprs-symbols-${sheet}.png')`), `missing sheet ${sheet}`); + } + assert.match(css, /\.aprs-symbol-overlaid\s*\{[^}]*aprs-symbols-24-2\.png'\), url\('\/vendor\/aprs-symbols-24-1\.png'\)/); +}); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs index 1b2d04ad..c322692b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/bundle-entry.mjs @@ -4,7 +4,7 @@ import { build } from "esbuild"; -export async function bundleEntry(entryUrl) { +export async function bundleEntry(entryUrl, globalName) { const result = await build({ entryPoints: [entryUrl.pathname], bundle: true, @@ -12,6 +12,7 @@ export async function bundleEntry(entryUrl) { platform: "browser", target: "es2022", write: false, + ...(globalName ? { globalName } : {}), }); const output = result.outputFiles[0]; if (!output) throw new Error(`No bundle output for ${entryUrl.pathname}`);