diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js
index b46ec68e..32f73063 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/leaflet-ais-tracksymbol.js
@@ -1,94 +1,98 @@
"use strict";
-(function() {
- if (typeof L === "undefined") return;
- function clamp(value, min, max) {
- return Math.max(min, Math.min(max, value));
- }
- function finiteAngle(value) {
- if (!Number.isFinite(value)) return null;
- const normalized = (Number(value) % 360 + 360) % 360;
- return normalized;
- }
- function svgColor(value, fallback) {
- const text = String(value || fallback || "");
- return text.replace(/"/g, """);
- }
- function buildSymbolHtml(options, zoom) {
- const heading = finiteAngle(options.heading);
- const course = finiteAngle(options.course);
- const angle = heading != null ? heading : course;
- const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
- const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
- const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
- const size = clamp(sizeBase + zoomBoost, 16, 32);
- const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
- const color = svgColor(options.color, "#ff7559");
- const outline = svgColor(options.outline, "#6b2118");
- const body = angle != null ? `` : ``;
- const courseLine = course != null ? `` : "";
- return ``;
- }
- L.TrxAisTrackSymbol = L.Marker.extend({
- options: {
- heading: null,
- course: null,
- speed: null,
- color: "#ff7559",
- outline: "#6b2118",
- size: 22,
- interactive: true,
- keyboard: true,
- riseOnHover: true
- },
- initialize: function(latlng, options) {
- const merged = L.Util.extend({}, this.options, options || {});
- merged.icon = L.divIcon({
- className: "trx-ais-track-symbol-icon",
- html: "",
- iconSize: [merged.size, merged.size],
- iconAnchor: [merged.size / 2, merged.size / 2]
- });
- L.Marker.prototype.initialize.call(this, latlng, merged);
- },
- onAdd: function(map) {
- L.Marker.prototype.onAdd.call(this, map);
- this._refreshIcon();
- this._boundZoomRefresh = this._refreshIcon.bind(this);
- map.on("zoomend", this._boundZoomRefresh);
- },
- onRemove: function(map) {
- if (this._boundZoomRefresh) {
- map.off("zoomend", this._boundZoomRefresh);
- this._boundZoomRefresh = null;
- }
- L.Marker.prototype.onRemove.call(this, map);
- },
- setAisState: function(next) {
- if (next && typeof next === "object") {
+(() => {
+ // src/leaflet-ais-tracksymbol.ts
+ (function() {
+ const leaflet = globalThis.L;
+ if (!leaflet) return;
+ function clamp(value, min, max) {
+ return Math.max(min, Math.min(max, value));
+ }
+ function finiteAngle(value) {
+ if (value === null || !Number.isFinite(value)) return null;
+ const normalized = (value % 360 + 360) % 360;
+ return normalized;
+ }
+ function svgColor(value, fallback) {
+ const text = value || fallback || "";
+ return text.replace(/"/g, """);
+ }
+ function buildSymbolHtml(options, zoom) {
+ const heading = finiteAngle(options.heading);
+ const course = finiteAngle(options.course);
+ const angle = heading != null ? heading : course;
+ const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
+ const sizeBase = Number.isFinite(options.size) ? options.size : 22;
+ const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
+ const size = clamp(sizeBase + zoomBoost, 16, 32);
+ const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
+ const color = svgColor(options.color, "#ff7559");
+ const outline = svgColor(options.outline, "#6b2118");
+ const body = angle != null ? `` : ``;
+ const courseLine = course != null ? `` : "";
+ return ``;
+ }
+ leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
+ options: {
+ heading: null,
+ course: null,
+ speed: null,
+ color: "#ff7559",
+ outline: "#6b2118",
+ size: 22,
+ interactive: true,
+ keyboard: true,
+ riseOnHover: true
+ },
+ initialize: function(latlng, options) {
+ const merged = leaflet.Util.extend({}, this.options, options || {});
+ merged.icon = leaflet.divIcon({
+ className: "trx-ais-track-symbol-icon",
+ html: "",
+ iconSize: [merged.size, merged.size],
+ iconAnchor: [merged.size / 2, merged.size / 2]
+ });
+ leaflet.Marker.prototype.initialize.call(this, latlng, merged);
+ },
+ onAdd: function(map) {
+ leaflet.Marker.prototype.onAdd.call(this, map);
+ this._refreshIcon();
+ this._boundZoomRefresh = this._refreshIcon.bind(this);
+ map.on("zoomend", this._boundZoomRefresh);
+ },
+ onRemove: function(map) {
+ if (this._boundZoomRefresh) {
+ map.off("zoomend", this._boundZoomRefresh);
+ this._boundZoomRefresh = null;
+ }
+ leaflet.Marker.prototype.onRemove.call(this, map);
+ },
+ setAisState: function(next) {
if ("heading" in next) this.options.heading = next.heading;
if ("course" in next) this.options.course = next.course;
if ("speed" in next) this.options.speed = next.speed;
if ("color" in next) this.options.color = next.color;
if ("outline" in next) this.options.outline = next.outline;
+ this._refreshIcon();
+ return this;
+ },
+ _refreshIcon: function() {
+ if (!this._icon) return;
+ const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
+ const html = buildSymbolHtml(this.options, zoom);
+ this._icon.innerHTML = html;
+ const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22;
+ const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
+ const size = clamp(sizeBase + zoomBoost, 16, 32);
+ this._icon.style.width = `${size}px`;
+ this._icon.style.height = `${size}px`;
+ this._icon.style.marginLeft = `${-size / 2}px`;
+ this._icon.style.marginTop = `${-size / 2}px`;
}
- this._refreshIcon();
- return this;
- },
- _refreshIcon: function() {
- if (!this._icon) return;
- const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
- const html = buildSymbolHtml(this.options, zoom);
- this._icon.innerHTML = html;
- const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
- const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
- const size = clamp(sizeBase + zoomBoost, 16, 32);
- this._icon.style.width = `${size}px`;
- this._icon.style.height = `${size}px`;
- this._icon.style.marginLeft = `${-size / 2}px`;
- this._icon.style.marginTop = `${-size / 2}px`;
- }
- });
- L.trxAisTrackSymbol = function(latlng, options) {
- return new L.TrxAisTrackSymbol(latlng, options);
- };
+ });
+ leaflet.trxAisTrackSymbol = function(latlng, options) {
+ const Constructor = leaflet.TrxAisTrackSymbol;
+ if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
+ return new Constructor(latlng, options);
+ };
+ })();
})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
index c73bd27f..0cb620bb 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/build.mjs
@@ -21,7 +21,6 @@ await build({
"map-core": path.join(sourceDir, "map-core.js"),
screenshot: path.join(sourceDir, "screenshot.ts"),
"webgl-renderer": path.join(sourceDir, "webgl-renderer.ts"),
- "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.js"),
ais: path.join(sourceDir, "plugins", "ais.js"),
aprs: path.join(sourceDir, "plugins", "aprs.js"),
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
@@ -50,6 +49,7 @@ await build({
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
cw: path.join(sourceDir, "plugins", "cw.ts"),
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
+ "leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"),
},
outdir: outputDir,
bundle: true,
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js
index 4b367188..13f1783c 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/eslint.config.js
@@ -37,6 +37,7 @@ export default tseslint.config(
rules: {
"no-undef": "off",
"@typescript-eslint/no-explicit-any": "error",
+ "@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
},
},
);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js
deleted file mode 100644
index ad0d13b3..00000000
--- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.js
+++ /dev/null
@@ -1,120 +0,0 @@
-(function() {
- if (typeof L === "undefined") return;
-
- function clamp(value, min, max) {
- return Math.max(min, Math.min(max, value));
- }
-
- function finiteAngle(value) {
- if (!Number.isFinite(value)) return null;
- const normalized = ((Number(value) % 360) + 360) % 360;
- return normalized;
- }
-
- function svgColor(value, fallback) {
- const text = String(value || fallback || "");
- return text.replace(/"/g, """);
- }
-
- function buildSymbolHtml(options, zoom) {
- const heading = finiteAngle(options.heading);
- const course = finiteAngle(options.course);
- const angle = heading != null ? heading : course;
- const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
- const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
- const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
- const size = clamp(sizeBase + zoomBoost, 16, 32);
- const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
- const color = svgColor(options.color, "#ff7559");
- const outline = svgColor(options.outline, "#6b2118");
-
- const body = angle != null
- ? `` +
- `` +
- ``
- : ``;
-
- const courseLine = course != null
- ? `` +
- `` +
- ``
- : "";
-
- return (
- ``
- );
- }
-
- L.TrxAisTrackSymbol = L.Marker.extend({
- options: {
- heading: null,
- course: null,
- speed: null,
- color: "#ff7559",
- outline: "#6b2118",
- size: 22,
- interactive: true,
- keyboard: true,
- riseOnHover: true,
- },
-
- initialize: function(latlng, options) {
- const merged = L.Util.extend({}, this.options, options || {});
- merged.icon = L.divIcon({
- className: "trx-ais-track-symbol-icon",
- html: "",
- iconSize: [merged.size, merged.size],
- iconAnchor: [merged.size / 2, merged.size / 2],
- });
- L.Marker.prototype.initialize.call(this, latlng, merged);
- },
-
- onAdd: function(map) {
- L.Marker.prototype.onAdd.call(this, map);
- this._refreshIcon();
- this._boundZoomRefresh = this._refreshIcon.bind(this);
- map.on("zoomend", this._boundZoomRefresh);
- },
-
- onRemove: function(map) {
- if (this._boundZoomRefresh) {
- map.off("zoomend", this._boundZoomRefresh);
- this._boundZoomRefresh = null;
- }
- L.Marker.prototype.onRemove.call(this, map);
- },
-
- setAisState: function(next) {
- if (next && typeof next === "object") {
- if ("heading" in next) this.options.heading = next.heading;
- if ("course" in next) this.options.course = next.course;
- if ("speed" in next) this.options.speed = next.speed;
- if ("color" in next) this.options.color = next.color;
- if ("outline" in next) this.options.outline = next.outline;
- }
- this._refreshIcon();
- return this;
- },
-
- _refreshIcon: function() {
- if (!this._icon) return;
- const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
- const html = buildSymbolHtml(this.options, zoom);
- this._icon.innerHTML = html;
- const sizeBase = Number.isFinite(this.options.size) ? Number(this.options.size) : 22;
- const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
- const size = clamp(sizeBase + zoomBoost, 16, 32);
- this._icon.style.width = `${size}px`;
- this._icon.style.height = `${size}px`;
- this._icon.style.marginLeft = `${-size / 2}px`;
- this._icon.style.marginTop = `${-size / 2}px`;
- },
- });
-
- L.trxAisTrackSymbol = function(latlng, options) {
- return new L.TrxAisTrackSymbol(latlng, options);
- };
-})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.ts
new file mode 100644
index 00000000..a2c04e03
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/leaflet-ais-tracksymbol.ts
@@ -0,0 +1,163 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+export {};
+
+interface AisSymbolOptions {
+ heading: number | null;
+ course: number | null;
+ speed: number | null;
+ color: string;
+ outline: string;
+ size: number;
+ interactive: boolean;
+ keyboard: boolean;
+ riseOnHover: boolean;
+ icon?: unknown;
+}
+interface LeafletMapAdapter { on(event: string, listener: () => void): void; off(event: string, listener: () => void): void; getZoom(): number }
+interface TrackSymbolInstance {
+ options: AisSymbolOptions;
+ _icon?: HTMLElement;
+ _map?: LeafletMapAdapter;
+ _boundZoomRefresh?: (() => void) | null;
+ _refreshIcon(): void;
+}
+interface TrackSymbolConstructor { new(latlng: unknown, options?: Partial): TrackSymbolInstance }
+interface LeafletAdapter {
+ Marker: {
+ extend(definition: object & ThisType): TrackSymbolConstructor;
+ prototype: {
+ initialize(this: TrackSymbolInstance, latlng: unknown, options: AisSymbolOptions): void;
+ onAdd(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
+ onRemove(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
+ };
+ };
+ Util: { extend(target: T, ...sources: object[]): T };
+ divIcon(options: object): unknown;
+ TrxAisTrackSymbol?: TrackSymbolConstructor;
+ trxAisTrackSymbol?: (latlng: unknown, options?: Partial) => TrackSymbolInstance;
+}
+
+(function() {
+ const leaflet = (globalThis as unknown as { L?: LeafletAdapter }).L;
+ if (!leaflet) return;
+
+ function clamp(value: number, min: number, max: number): number {
+ return Math.max(min, Math.min(max, value));
+ }
+
+ function finiteAngle(value: number | null): number | null {
+ if (value === null || !Number.isFinite(value)) return null;
+ const normalized = ((value % 360) + 360) % 360;
+ return normalized;
+ }
+
+ function svgColor(value: string, fallback: string): string {
+ const text = value || fallback || "";
+ return text.replace(/"/g, """);
+ }
+
+ function buildSymbolHtml(options: AisSymbolOptions, zoom: number): string {
+ const heading = finiteAngle(options.heading);
+ const course = finiteAngle(options.course);
+ const angle = heading != null ? heading : course;
+ const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
+ const sizeBase = Number.isFinite(options.size) ? options.size : 22;
+ const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
+ const size = clamp(sizeBase + zoomBoost, 16, 32);
+ const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
+ const color = svgColor(options.color, "#ff7559");
+ const outline = svgColor(options.outline, "#6b2118");
+
+ const body = angle != null
+ ? `` +
+ `` +
+ ``
+ : ``;
+
+ const courseLine = course != null
+ ? `` +
+ `` +
+ ``
+ : "";
+
+ return (
+ ``
+ );
+ }
+
+ leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
+ options: {
+ heading: null,
+ course: null,
+ speed: null,
+ color: "#ff7559",
+ outline: "#6b2118",
+ size: 22,
+ interactive: true,
+ keyboard: true,
+ riseOnHover: true,
+ },
+
+ initialize: function(latlng: unknown, options?: Partial) {
+ const merged = leaflet.Util.extend({} as AisSymbolOptions, this.options, options || {});
+ merged.icon = leaflet.divIcon({
+ className: "trx-ais-track-symbol-icon",
+ html: "",
+ iconSize: [merged.size, merged.size],
+ iconAnchor: [merged.size / 2, merged.size / 2],
+ });
+ leaflet.Marker.prototype.initialize.call(this, latlng, merged);
+ },
+
+ onAdd: function(map: LeafletMapAdapter) {
+ leaflet.Marker.prototype.onAdd.call(this, map);
+ this._refreshIcon();
+ this._boundZoomRefresh = this._refreshIcon.bind(this);
+ map.on("zoomend", this._boundZoomRefresh);
+ },
+
+ onRemove: function(map: LeafletMapAdapter) {
+ if (this._boundZoomRefresh) {
+ map.off("zoomend", this._boundZoomRefresh);
+ this._boundZoomRefresh = null;
+ }
+ leaflet.Marker.prototype.onRemove.call(this, map);
+ },
+
+ setAisState: function(next: Partial) {
+ if ("heading" in next) this.options.heading = next.heading;
+ if ("course" in next) this.options.course = next.course;
+ if ("speed" in next) this.options.speed = next.speed;
+ if ("color" in next) this.options.color = next.color;
+ if ("outline" in next) this.options.outline = next.outline;
+ this._refreshIcon();
+ return this;
+ },
+
+ _refreshIcon: function() {
+ if (!this._icon) return;
+ const zoom = this._map && typeof this._map.getZoom === "function" ? this._map.getZoom() : 0;
+ const html = buildSymbolHtml(this.options, zoom);
+ this._icon.innerHTML = html;
+ const sizeBase = Number.isFinite(this.options.size) ? this.options.size : 22;
+ const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
+ const size = clamp(sizeBase + zoomBoost, 16, 32);
+ this._icon.style.width = `${size}px`;
+ this._icon.style.height = `${size}px`;
+ this._icon.style.marginLeft = `${-size / 2}px`;
+ this._icon.style.marginTop = `${-size / 2}px`;
+ },
+ });
+
+ leaflet.trxAisTrackSymbol = function(latlng: unknown, options?: Partial) {
+ const Constructor = leaflet.TrxAisTrackSymbol;
+ if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
+ return new Constructor(latlng, options);
+ };
+})();
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs
new file mode 100644
index 00000000..46ccbda6
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/leaflet-ais-tracksymbol.test.mjs
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: 2026 Stan Grams
+//
+// SPDX-License-Identifier: GPL-2.0-or-later
+
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import test from "node:test";
+import vm from "node:vm";
+
+test("local AIS track-symbol adapter renders immediately on first add", async () => {
+ const listeners = new Map();
+ const map = {
+ getZoom: () => 10,
+ on: (event, listener) => { listeners.set(event, listener); },
+ off: (event) => { listeners.delete(event); },
+ };
+ const markerPrototype = {
+ initialize(_latlng, options) { this.options = options; },
+ onAdd(addedMap) { this._map = addedMap; this._icon = { innerHTML: "", style: {} }; },
+ onRemove() { this._map = null; },
+ };
+ const L = {
+ Marker: {
+ prototype: markerPrototype,
+ extend(definition) {
+ return class TrackSymbol {
+ constructor(latlng, options) {
+ this.options = { ...definition.options };
+ definition.initialize.call(this, latlng, options);
+ }
+ };
+ },
+ },
+ Util: { extend: Object.assign },
+ divIcon: (options) => options,
+ };
+ const source = await readFile(
+ new URL("../../assets/web/generated/leaflet-ais-tracksymbol.js", import.meta.url),
+ "utf8",
+ );
+ new vm.Script(source).runInNewContext({ L, console });
+ Object.assign(L.TrxAisTrackSymbol.prototype, {
+ onAdd: markerPrototype.onAdd,
+ onRemove: markerPrototype.onRemove,
+ });
+ // The fake `extend` above only models construction; copy plugin methods from
+ // a second extend capture so the test exercises the generated adapter.
+ let definition;
+ L.Marker.extend = (value) => { definition = value; return L.TrxAisTrackSymbol; };
+ new vm.Script(source).runInNewContext({ L, console });
+ Object.assign(L.TrxAisTrackSymbol.prototype, definition);
+
+ const symbol = L.trxAisTrackSymbol([52, 21], { heading: 90, speed: 12 });
+ symbol.onAdd(map);
+ assert.match(symbol._icon.innerHTML, /