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, /