diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
index f2a042c..6f3c495 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js
@@ -3224,13 +3224,16 @@ function sizeAprsMapToViewport() {
const mapEl = document.getElementById("aprs-map");
if (!mapEl) return;
const mapRect = mapEl.getBoundingClientRect();
+ const width = mapEl.clientWidth || mapRect.width;
const footer = document.querySelector(".footer");
let bottom = window.innerHeight;
if (footer) {
const fr = footer.getBoundingClientRect();
if (fr.top > mapRect.top + 50) bottom = fr.top;
}
- const target = Math.max(150, Math.floor(bottom - mapRect.top - 8));
+ const available = Math.max(0, Math.floor(bottom - mapRect.top - 8));
+ const widthDriven = width > 0 ? Math.floor(width / 1.55) : available;
+ const target = Math.max(0, Math.min(available, widthDriven));
mapEl.style.height = `${target}px`;
if (aprsMap) aprsMap.invalidateSize();
}
@@ -3489,6 +3492,47 @@ function ensureAisTrack(mmsi, entry) {
entry.track = track;
}
+function aisMarkerOptionsFromMessage(msg) {
+ return {
+ heading: msg?.heading_deg,
+ course: msg?.cog_deg,
+ speed: msg?.sog_knots,
+ color: "#ff7559",
+ outline: "#6b2118",
+ size: 22,
+ };
+}
+
+function createAisMarker(lat, lon, msg) {
+ if (typeof L !== "undefined" && typeof L.trxAisTrackSymbol === "function") {
+ return L.trxAisTrackSymbol([lat, lon], aisMarkerOptionsFromMessage(msg));
+ }
+ return L.circleMarker([lat, lon], {
+ radius: 6,
+ color: "#e2553d",
+ fillColor: "#ff7559",
+ fillOpacity: 0.82,
+ });
+}
+
+function updateAisMarker(marker, msg, popupHtml) {
+ if (!marker) return;
+ marker.setLatLng([msg.lat, msg.lon]);
+ if (typeof marker.setAisState === "function") {
+ marker.setAisState(aisMarkerOptionsFromMessage(msg));
+ }
+ if (typeof marker.setStyle === "function" && typeof marker.setAisState !== "function") {
+ const hasHeading = Number.isFinite(msg?.heading_deg) || Number.isFinite(msg?.cog_deg);
+ marker.setStyle({
+ radius: hasHeading ? 6.5 : 6,
+ color: hasHeading ? "#c8412f" : "#e2553d",
+ fillColor: hasHeading ? "#ff6f4d" : "#ff7559",
+ fillOpacity: 0.84,
+ });
+ }
+ marker.setPopupContent(popupHtml);
+}
+
window.aisMapAddVessel = function(msg) {
if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
if (!aprsMap) initAprsMap();
@@ -3508,18 +3552,12 @@ window.aisMapAddVessel = function(msg) {
ensureAisTrack(key, existing);
}
if (existing.marker) {
- existing.marker.setLatLng([msg.lat, msg.lon]);
- existing.marker.setPopupContent(popupHtml);
+ updateAisMarker(existing.marker, msg, popupHtml);
}
return;
}
if (!aprsMap) return;
- const marker = L.circleMarker([msg.lat, msg.lon], {
- radius: 6,
- color: "#e2553d",
- fillColor: "#ff7559",
- fillOpacity: 0.82,
- }).addTo(aprsMap).bindPopup(popupHtml);
+ const marker = createAisMarker(msg.lat, msg.lon, msg).addTo(aprsMap).bindPopup(popupHtml);
marker.__trxType = "ais";
marker._aisMmsi = key;
mapMarkers.add(marker);
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
index 2b9a54c..516ff27 100644
--- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html
@@ -11,6 +11,7 @@
+
diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js
new file mode 100644
index 0000000..94b704d
--- /dev/null
+++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/leaflet-ais-tracksymbol.js
@@ -0,0 +1,118 @@
+(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;
+ }
+ 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/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css
index 3e30c35..68a74c9 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
@@ -1086,7 +1086,18 @@ small { color: var(--text-muted); }
.sub-tab { flex-shrink: 0; background: transparent; border: none; border-bottom: 2px solid transparent; border-radius: 0; padding: 0.35rem 0.75rem; color: var(--text-muted); cursor: pointer; font-size: 0.85rem; height: auto; }
.sub-tab.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
.sub-tab:hover:not(.active) { color: var(--text); }
-#aprs-map { min-height: 150px; border-radius: 6px; }
+#tab-map {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+ min-height: 0;
+}
+#aprs-map {
+ flex: 0 1 auto;
+ width: 100%;
+ min-height: 0;
+ border-radius: 6px;
+}
.aprs-controls { display: flex; gap: 0.6rem; align-items: center; margin-bottom: 0.75rem; }
#subtab-aprs .aprs-controls > button {
min-height: 1.65rem;