refactor: type the local Leaflet AIS adapter
This commit is contained in:
+90
-86
@@ -1,94 +1,98 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
(function() {
|
(() => {
|
||||||
if (typeof L === "undefined") return;
|
// src/leaflet-ais-tracksymbol.ts
|
||||||
function clamp(value, min, max) {
|
(function() {
|
||||||
return Math.max(min, Math.min(max, value));
|
const leaflet = globalThis.L;
|
||||||
}
|
if (!leaflet) return;
|
||||||
function finiteAngle(value) {
|
function clamp(value, min, max) {
|
||||||
if (!Number.isFinite(value)) return null;
|
return Math.max(min, Math.min(max, value));
|
||||||
const normalized = (Number(value) % 360 + 360) % 360;
|
}
|
||||||
return normalized;
|
function finiteAngle(value) {
|
||||||
}
|
if (value === null || !Number.isFinite(value)) return null;
|
||||||
function svgColor(value, fallback) {
|
const normalized = (value % 360 + 360) % 360;
|
||||||
const text = String(value || fallback || "");
|
return normalized;
|
||||||
return text.replace(/"/g, """);
|
}
|
||||||
}
|
function svgColor(value, fallback) {
|
||||||
function buildSymbolHtml(options, zoom) {
|
const text = value || fallback || "";
|
||||||
const heading = finiteAngle(options.heading);
|
return text.replace(/"/g, """);
|
||||||
const course = finiteAngle(options.course);
|
}
|
||||||
const angle = heading != null ? heading : course;
|
function buildSymbolHtml(options, zoom) {
|
||||||
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
const heading = finiteAngle(options.heading);
|
||||||
const sizeBase = Number.isFinite(options.size) ? Number(options.size) : 22;
|
const course = finiteAngle(options.course);
|
||||||
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
const angle = heading != null ? heading : course;
|
||||||
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
const speed = Number.isFinite(options.speed) ? Math.max(0, Number(options.speed)) : 0;
|
||||||
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
const sizeBase = Number.isFinite(options.size) ? options.size : 22;
|
||||||
const color = svgColor(options.color, "#ff7559");
|
const zoomBoost = zoom >= 12 ? 4 : zoom >= 9 ? 2 : 0;
|
||||||
const outline = svgColor(options.outline, "#6b2118");
|
const size = clamp(sizeBase + zoomBoost, 16, 32);
|
||||||
const body = angle != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})"><path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" /></g>` : `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
const courseLen = course != null ? clamp(size * (0.55 + Math.min(speed, 30) / 30), size * 0.55, size * 1.2) : 0;
|
||||||
const courseLine = course != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})"><line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" /></g>` : "";
|
const color = svgColor(options.color, "#ff7559");
|
||||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` + courseLine + body + `</svg>`;
|
const outline = svgColor(options.outline, "#6b2118");
|
||||||
}
|
const body = angle != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})"><path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" /></g>` : `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||||
L.TrxAisTrackSymbol = L.Marker.extend({
|
const courseLine = course != null ? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})"><line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" /></g>` : "";
|
||||||
options: {
|
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` + courseLine + body + `</svg>`;
|
||||||
heading: null,
|
}
|
||||||
course: null,
|
leaflet.TrxAisTrackSymbol = leaflet.Marker.extend({
|
||||||
speed: null,
|
options: {
|
||||||
color: "#ff7559",
|
heading: null,
|
||||||
outline: "#6b2118",
|
course: null,
|
||||||
size: 22,
|
speed: null,
|
||||||
interactive: true,
|
color: "#ff7559",
|
||||||
keyboard: true,
|
outline: "#6b2118",
|
||||||
riseOnHover: true
|
size: 22,
|
||||||
},
|
interactive: true,
|
||||||
initialize: function(latlng, options) {
|
keyboard: true,
|
||||||
const merged = L.Util.extend({}, this.options, options || {});
|
riseOnHover: true
|
||||||
merged.icon = L.divIcon({
|
},
|
||||||
className: "trx-ais-track-symbol-icon",
|
initialize: function(latlng, options) {
|
||||||
html: "",
|
const merged = leaflet.Util.extend({}, this.options, options || {});
|
||||||
iconSize: [merged.size, merged.size],
|
merged.icon = leaflet.divIcon({
|
||||||
iconAnchor: [merged.size / 2, merged.size / 2]
|
className: "trx-ais-track-symbol-icon",
|
||||||
});
|
html: "",
|
||||||
L.Marker.prototype.initialize.call(this, latlng, merged);
|
iconSize: [merged.size, merged.size],
|
||||||
},
|
iconAnchor: [merged.size / 2, merged.size / 2]
|
||||||
onAdd: function(map) {
|
});
|
||||||
L.Marker.prototype.onAdd.call(this, map);
|
leaflet.Marker.prototype.initialize.call(this, latlng, merged);
|
||||||
this._refreshIcon();
|
},
|
||||||
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
onAdd: function(map) {
|
||||||
map.on("zoomend", this._boundZoomRefresh);
|
leaflet.Marker.prototype.onAdd.call(this, map);
|
||||||
},
|
this._refreshIcon();
|
||||||
onRemove: function(map) {
|
this._boundZoomRefresh = this._refreshIcon.bind(this);
|
||||||
if (this._boundZoomRefresh) {
|
map.on("zoomend", this._boundZoomRefresh);
|
||||||
map.off("zoomend", this._boundZoomRefresh);
|
},
|
||||||
this._boundZoomRefresh = null;
|
onRemove: function(map) {
|
||||||
}
|
if (this._boundZoomRefresh) {
|
||||||
L.Marker.prototype.onRemove.call(this, map);
|
map.off("zoomend", this._boundZoomRefresh);
|
||||||
},
|
this._boundZoomRefresh = null;
|
||||||
setAisState: function(next) {
|
}
|
||||||
if (next && typeof next === "object") {
|
leaflet.Marker.prototype.onRemove.call(this, map);
|
||||||
|
},
|
||||||
|
setAisState: function(next) {
|
||||||
if ("heading" in next) this.options.heading = next.heading;
|
if ("heading" in next) this.options.heading = next.heading;
|
||||||
if ("course" in next) this.options.course = next.course;
|
if ("course" in next) this.options.course = next.course;
|
||||||
if ("speed" in next) this.options.speed = next.speed;
|
if ("speed" in next) this.options.speed = next.speed;
|
||||||
if ("color" in next) this.options.color = next.color;
|
if ("color" in next) this.options.color = next.color;
|
||||||
if ("outline" in next) this.options.outline = next.outline;
|
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;
|
leaflet.trxAisTrackSymbol = function(latlng, options) {
|
||||||
},
|
const Constructor = leaflet.TrxAisTrackSymbol;
|
||||||
_refreshIcon: function() {
|
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
||||||
if (!this._icon) return;
|
return new Constructor(latlng, options);
|
||||||
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);
|
|
||||||
};
|
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ await build({
|
|||||||
"map-core": path.join(sourceDir, "map-core.js"),
|
"map-core": path.join(sourceDir, "map-core.js"),
|
||||||
screenshot: path.join(sourceDir, "screenshot.ts"),
|
screenshot: path.join(sourceDir, "screenshot.ts"),
|
||||||
"webgl-renderer": path.join(sourceDir, "webgl-renderer.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"),
|
ais: path.join(sourceDir, "plugins", "ais.js"),
|
||||||
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
aprs: path.join(sourceDir, "plugins", "aprs.js"),
|
||||||
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
|
"background-decode": path.join(sourceDir, "plugins", "background-decode.js"),
|
||||||
@@ -50,6 +49,7 @@ await build({
|
|||||||
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
wspr: path.join(sourceDir, "plugins", "wspr.ts"),
|
||||||
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
cw: path.join(sourceDir, "plugins", "cw.ts"),
|
||||||
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
ft8: path.join(sourceDir, "plugins", "ft8.ts"),
|
||||||
|
"leaflet-ais-tracksymbol": path.join(sourceDir, "leaflet-ais-tracksymbol.ts"),
|
||||||
},
|
},
|
||||||
outdir: outputDir,
|
outdir: outputDir,
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export default tseslint.config(
|
|||||||
rules: {
|
rules: {
|
||||||
"no-undef": "off",
|
"no-undef": "off",
|
||||||
"@typescript-eslint/no-explicit-any": "error",
|
"@typescript-eslint/no-explicit-any": "error",
|
||||||
|
"@typescript-eslint/restrict-template-expressions": ["error", { allowNumber: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
-120
@@ -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
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
|
||||||
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
|
||||||
`</g>`
|
|
||||||
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
|
||||||
|
|
||||||
const courseLine = course != null
|
|
||||||
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
|
||||||
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
|
||||||
`</g>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return (
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
|
||||||
courseLine +
|
|
||||||
body +
|
|
||||||
`</svg>`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
};
|
|
||||||
})();
|
|
||||||
+163
@@ -0,0 +1,163 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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<AisSymbolOptions>): TrackSymbolInstance }
|
||||||
|
interface LeafletAdapter {
|
||||||
|
Marker: {
|
||||||
|
extend(definition: object & ThisType<TrackSymbolInstance>): TrackSymbolConstructor;
|
||||||
|
prototype: {
|
||||||
|
initialize(this: TrackSymbolInstance, latlng: unknown, options: AisSymbolOptions): void;
|
||||||
|
onAdd(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
|
||||||
|
onRemove(this: TrackSymbolInstance, map: LeafletMapAdapter): void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
Util: { extend<T extends object>(target: T, ...sources: object[]): T };
|
||||||
|
divIcon(options: object): unknown;
|
||||||
|
TrxAisTrackSymbol?: TrackSymbolConstructor;
|
||||||
|
trxAisTrackSymbol?: (latlng: unknown, options?: Partial<AisSymbolOptions>) => 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
|
||||||
|
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${angle}) translate(${-size / 2} ${-size / 2})">` +
|
||||||
|
`<path d="M ${size * 0.5} ${size * 0.06} L ${size * 0.82} ${size * 0.78} L ${size * 0.5} ${size * 0.62} L ${size * 0.18} ${size * 0.78} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />` +
|
||||||
|
`</g>`
|
||||||
|
: `<path d="M ${size * 0.5} ${size * 0.12} L ${size * 0.88} ${size * 0.5} L ${size * 0.5} ${size * 0.88} L ${size * 0.12} ${size * 0.5} Z" fill="${color}" stroke="${outline}" stroke-width="1.2" stroke-linejoin="round" />`;
|
||||||
|
|
||||||
|
const courseLine = course != null
|
||||||
|
? `<g transform="translate(${size / 2} ${size / 2}) rotate(${course})">` +
|
||||||
|
`<line x1="0" y1="${-size * 0.22}" x2="0" y2="${-(size * 0.22 + courseLen)}" stroke="${color}" stroke-width="1.4" stroke-linecap="round" opacity="0.75" />` +
|
||||||
|
`</g>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}" aria-hidden="true">` +
|
||||||
|
courseLine +
|
||||||
|
body +
|
||||||
|
`</svg>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<AisSymbolOptions>) {
|
||||||
|
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<AisSymbolOptions>) {
|
||||||
|
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<AisSymbolOptions>) {
|
||||||
|
const Constructor = leaflet.TrxAisTrackSymbol;
|
||||||
|
if (!Constructor) throw new Error("AIS track symbol constructor is unavailable");
|
||||||
|
return new Constructor(latlng, options);
|
||||||
|
};
|
||||||
|
})();
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
// SPDX-FileCopyrightText: 2026 Stan Grams <sjg@haxx.space>
|
||||||
|
//
|
||||||
|
// 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, /<svg/);
|
||||||
|
assert.match(symbol._icon.innerHTML, /rotate\(90\)/);
|
||||||
|
assert.equal(listeners.has("zoomend"), true);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user