[feat](trx-frontend): add AIS vessel symbols on map
Render AIS vessels with heading-aware ship symbols, keep selected tracks on click, and size the map to fit the viewport cleanly without overextending the page. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="/leaflet-ais-tracksymbol.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" id="card">
|
||||
|
||||
@@ -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
|
||||
? `<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;
|
||||
}
|
||||
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);
|
||||
};
|
||||
})();
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user