[feat](trx-frontend-http): draw APRS symbols from the sprite sheets
CI / lint (pull_request) Successful in 2m16s
CI / test (pull_request) Successful in 8m12s
CI / frontend (pull_request) Successful in 3m2s
CI / reuse (pull_request) Successful in 3s
CI / lint (push) Successful in 2m24s
CI / test (push) Successful in 7m28s
CI / frontend (push) Successful in 2m11s
CI / reuse (push) Successful in 3s

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018huL1ELyr86yVqfAabtioA
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit was merged in pull request #37.
This commit is contained in:
sjg
2026-08-03 19:55:50 +02:00
co-authored by Claude Opus 5
parent 02fe492dbf
commit 5b7dd493d4
9 changed files with 235 additions and 8 deletions
@@ -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
? `<div class="aprs-symbol aprs-symbol-marker ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeMapHtml(sprite.label)}" aria-label="${escapeMapHtml(sprite.label)}"></div>`
: `<div class="aprs-symbol aprs-symbol-marker aprs-symbol-local" title="${symbolTable === "/" ? "primary" : "alternate"} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`;
return L.divIcon({
className: "",
html: `<div class="aprs-symbol-local" title="${table} APRS symbol ${escapeMapHtml(symbolCode)}">${escapeMapHtml(symbolCode)}</div>`,
html,
iconSize: [24, 24],
iconAnchor: [12, 12],
popupAnchor: [0, -12]
@@ -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 `<span class="aprs-symbol ${sprite.className}" role="img"` +
` style="background-position:${sprite.backgroundPosition}"` +
` title="${escapeHtml(sprite.label)}" aria-label="${escapeHtml(sprite.label)}"></span>`;
}
const symbol = escapeHtml(packet.symbolCode);
const table = packet.symbolTable === "/" ? "Primary" : "Alternate";
return `<span class="aprs-symbol aprs-symbol-local" title="${table} APRS symbol ${symbol}">${symbol}</span>`;
@@ -0,0 +1,85 @@
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
//
// 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),
'<span class="aprs-symbol aprs-symbol-local" title="Primary APRS symbol "> </span>');
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'\)/);
});
@@ -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}`);