[fix](trx-frontend-http): restore map history window state
Keep map history data cached when the history window is reduced so older APRS, AIS, VDES, FT8, and WSPR items can be shown again when the user expands the window, and add a global decode-history replay overlay with progress updates across the UI. Also update the longest QSO summary to render bidirectional contacts with <-> labels. Verification: node --check src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js Verification: git diff --check -- src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -58,6 +58,7 @@ async function authLogout() {
|
|||||||
authRole = null;
|
authRole = null;
|
||||||
// Disconnect and show auth gate without page reload
|
// Disconnect and show auth gate without page reload
|
||||||
disconnect();
|
disconnect();
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
document.getElementById("content").style.display = "none";
|
document.getElementById("content").style.display = "none";
|
||||||
document.getElementById("loading").style.display = "none";
|
document.getElementById("loading").style.display = "none";
|
||||||
document.getElementById("auth-passphrase").value = "";
|
document.getElementById("auth-passphrase").value = "";
|
||||||
@@ -75,6 +76,7 @@ async function authLogout() {
|
|||||||
|
|
||||||
function showAuthGate(allowGuest = false) {
|
function showAuthGate(allowGuest = false) {
|
||||||
if (!authEnabled) return;
|
if (!authEnabled) return;
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
document.getElementById("loading").style.display = "none";
|
document.getElementById("loading").style.display = "none";
|
||||||
document.getElementById("content").style.display = "none";
|
document.getElementById("content").style.display = "none";
|
||||||
const authGate = document.getElementById("auth-gate");
|
const authGate = document.getElementById("auth-gate");
|
||||||
@@ -324,6 +326,9 @@ const rigSubtitle = document.getElementById("rig-subtitle");
|
|||||||
const ownerSubtitle = document.getElementById("owner-subtitle");
|
const ownerSubtitle = document.getElementById("owner-subtitle");
|
||||||
const loadingTitle = document.getElementById("loading-title");
|
const loadingTitle = document.getElementById("loading-title");
|
||||||
const loadingSub = document.getElementById("loading-sub");
|
const loadingSub = document.getElementById("loading-sub");
|
||||||
|
const decodeHistoryOverlayEl = document.getElementById("decode-history-overlay");
|
||||||
|
const decodeHistoryOverlayTitleEl = document.getElementById("decode-history-overlay-title");
|
||||||
|
const decodeHistoryOverlaySubEl = document.getElementById("decode-history-overlay-sub");
|
||||||
const overviewCanvas = document.getElementById("overview-canvas");
|
const overviewCanvas = document.getElementById("overview-canvas");
|
||||||
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
const signalOverlayCanvas = document.getElementById("signal-overlay-canvas");
|
||||||
const overviewGl = typeof createTrxWebGlRenderer === "function"
|
const overviewGl = typeof createTrxWebGlRenderer === "function"
|
||||||
@@ -368,6 +373,12 @@ function syncTopBarAccess() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let overviewDrawPending = false;
|
let overviewDrawPending = false;
|
||||||
|
function setDecodeHistoryOverlayVisible(visible, title = "", sub = "") {
|
||||||
|
if (!decodeHistoryOverlayEl) return;
|
||||||
|
if (title && decodeHistoryOverlayTitleEl) decodeHistoryOverlayTitleEl.textContent = title;
|
||||||
|
if (decodeHistoryOverlaySubEl) decodeHistoryOverlaySubEl.textContent = sub || "";
|
||||||
|
decodeHistoryOverlayEl.classList.toggle("is-hidden", !visible);
|
||||||
|
}
|
||||||
let lastSpectrumData = null;
|
let lastSpectrumData = null;
|
||||||
window.lastSpectrumData = null;
|
window.lastSpectrumData = null;
|
||||||
let lastControl;
|
let lastControl;
|
||||||
@@ -2941,6 +2952,7 @@ function disconnect() {
|
|||||||
clearTimeout(reconnectTimer);
|
clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = null;
|
reconnectTimer = null;
|
||||||
}
|
}
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Yield the main thread so the browser can paint before heavy async work.
|
// Yield the main thread so the browser can paint before heavy async work.
|
||||||
@@ -3919,9 +3931,72 @@ function removeMapMarker(marker) {
|
|||||||
mapMarkers.delete(marker);
|
mapMarkers.delete(marker);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRetainedMapMarkerVisible(marker, visible) {
|
||||||
|
if (!marker) return;
|
||||||
|
marker.__trxHistoryVisible = visible !== false;
|
||||||
|
if (!visible) {
|
||||||
|
if (marker === selectedLocatorMarker) {
|
||||||
|
setSelectedLocatorMarker(null);
|
||||||
|
clearMapRadioPath();
|
||||||
|
}
|
||||||
|
if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAprsMarker(call, entry) {
|
||||||
|
if (!aprsMap || !entry || entry.marker || entry.lat == null || entry.lon == null) return;
|
||||||
|
_aprsAddMarkerToMap(call, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAisMarker(key, entry) {
|
||||||
|
if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return;
|
||||||
|
const marker = createAisMarker(entry.msg.lat, entry.msg.lon, entry.msg)
|
||||||
|
.addTo(aprsMap)
|
||||||
|
.bindPopup(buildAisPopupHtml(entry.msg));
|
||||||
|
marker.__trxType = "ais";
|
||||||
|
marker._aisMmsi = String(key);
|
||||||
|
entry.marker = marker;
|
||||||
|
mapMarkers.add(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureVdesMarker(key, entry) {
|
||||||
|
if (!aprsMap || !entry || entry.marker || entry?.msg?.lat == null || entry?.msg?.lon == null) return;
|
||||||
|
const marker = L.circleMarker([entry.msg.lat, entry.msg.lon], {
|
||||||
|
radius: 5,
|
||||||
|
color: "#5c394f",
|
||||||
|
fillColor: "#c46392",
|
||||||
|
fillOpacity: 0.82,
|
||||||
|
}).addTo(aprsMap).bindPopup(buildVdesPopupHtml(entry.msg));
|
||||||
|
marker.__trxType = "vdes";
|
||||||
|
marker._vdesKey = String(key);
|
||||||
|
entry.marker = marker;
|
||||||
|
mapMarkers.add(marker);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDecodeLocatorMarker(entry) {
|
||||||
|
if (!aprsMap || !entry || entry.marker || !entry.grid || (entry.sourceType !== "ft8" && entry.sourceType !== "wspr")) return;
|
||||||
|
const bounds = maidenheadToBounds(entry.grid);
|
||||||
|
if (!bounds) return;
|
||||||
|
const count = Math.max(entry.stationDetails?.size || 0, entry.stations?.size || 0, 1);
|
||||||
|
const tooltipHtml = buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType);
|
||||||
|
const marker = L.rectangle(bounds, locatorStyleForEntry(entry, count))
|
||||||
|
.addTo(aprsMap)
|
||||||
|
.bindPopup(tooltipHtml);
|
||||||
|
marker.__trxType = entry.sourceType;
|
||||||
|
sendLocatorOverlayToBack(marker);
|
||||||
|
assignLocatorMarkerMeta(marker, entry.sourceType, entry.bandMeta);
|
||||||
|
entry.marker = marker;
|
||||||
|
mapMarkers.add(marker);
|
||||||
|
}
|
||||||
|
|
||||||
function pruneAprsEntry(call, entry, cutoffMs) {
|
function pruneAprsEntry(call, entry, cutoffMs) {
|
||||||
const pktTsMs = Number(entry?.pkt?._tsMs);
|
const pktTsMs = Number(entry?.pkt?._tsMs);
|
||||||
if (!Number.isFinite(pktTsMs) || pktTsMs < cutoffMs) {
|
const visible = Number.isFinite(pktTsMs) && pktTsMs >= cutoffMs;
|
||||||
|
entry.visibleInHistoryWindow = visible;
|
||||||
|
entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS)
|
||||||
|
.map((point) => [point.lat, point.lon]);
|
||||||
|
refreshAprsTrack(call, entry);
|
||||||
|
if (!visible) {
|
||||||
if (selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
|
if (selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
|
||||||
selectedAprsTrackCall = null;
|
selectedAprsTrackCall = null;
|
||||||
}
|
}
|
||||||
@@ -3929,19 +4004,26 @@ function pruneAprsEntry(call, entry, cutoffMs) {
|
|||||||
entry.track.remove();
|
entry.track.remove();
|
||||||
entry.track = null;
|
entry.track = null;
|
||||||
}
|
}
|
||||||
removeMapMarker(entry?.marker);
|
setRetainedMapMarkerVisible(entry?.marker, false);
|
||||||
stationMarkers.delete(call);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.trackHistory = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS);
|
ensureAprsMarker(call, entry);
|
||||||
entry.trackPoints = entry.trackHistory.map((point) => [point.lat, point.lon]);
|
setRetainedMapMarkerVisible(entry?.marker, true);
|
||||||
refreshAprsTrack(call, entry);
|
if (entry?.marker) {
|
||||||
|
entry.marker.setLatLng([entry.lat, entry.lon]);
|
||||||
|
entry.marker.setPopupContent(buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneAisEntry(key, entry, cutoffMs) {
|
function pruneAisEntry(key, entry, cutoffMs) {
|
||||||
const msgTsMs = Number(entry?.msg?._tsMs);
|
const msgTsMs = Number(entry?.msg?._tsMs);
|
||||||
if (!Number.isFinite(msgTsMs) || msgTsMs < cutoffMs) {
|
const visible = Number.isFinite(msgTsMs) && msgTsMs >= cutoffMs;
|
||||||
|
entry.visibleInHistoryWindow = visible;
|
||||||
|
entry.trackPoints = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS)
|
||||||
|
.map((point) => [point.lat, point.lon]);
|
||||||
|
refreshAisTrack(key, entry);
|
||||||
|
if (!visible) {
|
||||||
if (selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
|
if (selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
|
||||||
selectedAisTrackMmsi = null;
|
selectedAisTrackMmsi = null;
|
||||||
}
|
}
|
||||||
@@ -3949,28 +4031,37 @@ function pruneAisEntry(key, entry, cutoffMs) {
|
|||||||
entry.track.remove();
|
entry.track.remove();
|
||||||
entry.track = null;
|
entry.track = null;
|
||||||
}
|
}
|
||||||
removeMapMarker(entry?.marker);
|
setRetainedMapMarkerVisible(entry?.marker, false);
|
||||||
aisMarkers.delete(key);
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
entry.trackHistory = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS);
|
ensureAisMarker(key, entry);
|
||||||
entry.trackPoints = entry.trackHistory.map((point) => [point.lat, point.lon]);
|
setRetainedMapMarkerVisible(entry?.marker, true);
|
||||||
refreshAisTrack(key, entry);
|
if (entry?.marker) {
|
||||||
|
updateAisMarker(entry.marker, entry.msg, buildAisPopupHtml(entry.msg));
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function pruneLocatorEntry(key, entry, cutoffMs) {
|
function pruneLocatorEntry(key, entry, cutoffMs) {
|
||||||
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "wspr")) return true;
|
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "wspr")) return true;
|
||||||
|
if (!(entry.allStationDetails instanceof Map)) {
|
||||||
|
entry.allStationDetails = entry.stationDetails instanceof Map
|
||||||
|
? new Map(entry.stationDetails)
|
||||||
|
: new Map();
|
||||||
|
}
|
||||||
const nextDetails = new Map();
|
const nextDetails = new Map();
|
||||||
for (const [detailKey, detail] of entry.stationDetails instanceof Map ? entry.stationDetails.entries() : []) {
|
for (const [detailKey, detail] of entry.allStationDetails.entries()) {
|
||||||
const tsMs = Number(detail?.ts_ms);
|
const tsMs = Number(detail?.ts_ms);
|
||||||
if (Number.isFinite(tsMs) && tsMs >= cutoffMs) {
|
if (Number.isFinite(tsMs) && tsMs >= cutoffMs) {
|
||||||
nextDetails.set(detailKey, detail);
|
nextDetails.set(detailKey, detail);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
entry.visibleInHistoryWindow = nextDetails.size > 0;
|
||||||
if (nextDetails.size === 0) {
|
if (nextDetails.size === 0) {
|
||||||
removeMapMarker(entry.marker);
|
entry.stationDetails = new Map();
|
||||||
locatorMarkers.delete(key);
|
entry.stations = new Set();
|
||||||
|
entry.bandMeta = new Map();
|
||||||
|
setRetainedMapMarkerVisible(entry.marker, false);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const nextStations = new Set();
|
const nextStations = new Set();
|
||||||
@@ -3984,6 +4075,8 @@ function pruneLocatorEntry(key, entry, cutoffMs) {
|
|||||||
Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz))
|
Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz))
|
||||||
);
|
);
|
||||||
const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
|
const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
|
||||||
|
ensureDecodeLocatorMarker(entry);
|
||||||
|
setRetainedMapMarkerVisible(entry.marker, true);
|
||||||
if (entry.marker) {
|
if (entry.marker) {
|
||||||
entry.marker.setStyle(locatorStyleForEntry(entry, count));
|
entry.marker.setStyle(locatorStyleForEntry(entry, count));
|
||||||
entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType));
|
entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType));
|
||||||
@@ -3994,19 +4087,28 @@ function pruneLocatorEntry(key, entry, cutoffMs) {
|
|||||||
|
|
||||||
function pruneMapHistory() {
|
function pruneMapHistory() {
|
||||||
const cutoffMs = mapHistoryCutoffMs();
|
const cutoffMs = mapHistoryCutoffMs();
|
||||||
for (const [call, entry] of Array.from(stationMarkers.entries())) {
|
for (const [call, entry] of stationMarkers.entries()) {
|
||||||
pruneAprsEntry(call, entry, cutoffMs);
|
pruneAprsEntry(call, entry, cutoffMs);
|
||||||
}
|
}
|
||||||
for (const [key, entry] of Array.from(aisMarkers.entries())) {
|
for (const [key, entry] of aisMarkers.entries()) {
|
||||||
pruneAisEntry(key, entry, cutoffMs);
|
pruneAisEntry(key, entry, cutoffMs);
|
||||||
}
|
}
|
||||||
for (const [key, entry] of Array.from(vdesMarkers.entries())) {
|
for (const [key, entry] of vdesMarkers.entries()) {
|
||||||
const tsMs = Number(entry?.msg?._tsMs);
|
const tsMs = Number(entry?.msg?._tsMs);
|
||||||
if (Number.isFinite(tsMs) && tsMs >= cutoffMs) continue;
|
const visible = Number.isFinite(tsMs) && tsMs >= cutoffMs;
|
||||||
removeMapMarker(entry?.marker);
|
entry.visibleInHistoryWindow = visible;
|
||||||
vdesMarkers.delete(key);
|
if (!visible) {
|
||||||
|
setRetainedMapMarkerVisible(entry?.marker, false);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ensureVdesMarker(key, entry);
|
||||||
|
setRetainedMapMarkerVisible(entry?.marker, true);
|
||||||
|
if (entry?.marker) {
|
||||||
|
entry.marker.setLatLng([entry.msg.lat, entry.msg.lon]);
|
||||||
|
entry.marker.setPopupContent(buildVdesPopupHtml(entry.msg));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
for (const [key, entry] of Array.from(locatorMarkers.entries())) {
|
for (const [key, entry] of locatorMarkers.entries()) {
|
||||||
pruneLocatorEntry(key, entry, cutoffMs);
|
pruneLocatorEntry(key, entry, cutoffMs);
|
||||||
}
|
}
|
||||||
rebuildDecodeContactPaths();
|
rebuildDecodeContactPaths();
|
||||||
@@ -4566,10 +4668,20 @@ function rebuildMapLocatorFilters() {
|
|||||||
const choiceLabelEl = document.getElementById("map-locator-choice-label");
|
const choiceLabelEl = document.getElementById("map-locator-choice-label");
|
||||||
|
|
||||||
const availableSources = new Set();
|
const availableSources = new Set();
|
||||||
if (aisMarkers.size > 0) availableSources.add("ais");
|
for (const entry of aisMarkers.values()) {
|
||||||
if (vdesMarkers.size > 0) availableSources.add("vdes");
|
if (entry?.visibleInHistoryWindow) {
|
||||||
|
availableSources.add("ais");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const entry of vdesMarkers.values()) {
|
||||||
|
if (entry?.visibleInHistoryWindow) {
|
||||||
|
availableSources.add("vdes");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const entry of stationMarkers.values()) {
|
for (const entry of stationMarkers.values()) {
|
||||||
if (entry?.type === "aprs" && (entry.marker || (entry.lat != null && entry.lon != null))) {
|
if (entry?.type === "aprs" && entry?.visibleInHistoryWindow) {
|
||||||
availableSources.add("aprs");
|
availableSources.add("aprs");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -4578,6 +4690,7 @@ function rebuildMapLocatorFilters() {
|
|||||||
for (const entry of locatorMarkers.values()) {
|
for (const entry of locatorMarkers.values()) {
|
||||||
const sourceType = entry?.sourceType;
|
const sourceType = entry?.sourceType;
|
||||||
if (!sourceType) continue;
|
if (!sourceType) continue;
|
||||||
|
if ((sourceType === "ft8" || sourceType === "wspr") && !entry?.visibleInHistoryWindow) continue;
|
||||||
availableSources.add(sourceType);
|
availableSources.add(sourceType);
|
||||||
const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map();
|
const meta = entry?.bandMeta instanceof Map ? entry.bandMeta : new Map();
|
||||||
for (const [label, hz] of meta.entries()) {
|
for (const [label, hz] of meta.entries()) {
|
||||||
@@ -5050,8 +5163,8 @@ function initAprsMap() {
|
|||||||
|
|
||||||
// Materialise any stations that were buffered before the map was ready
|
// Materialise any stations that were buffered before the map was ready
|
||||||
for (const [call, entry] of stationMarkers) {
|
for (const [call, entry] of stationMarkers) {
|
||||||
if (entry.type === "aprs" && !entry.marker && entry.lat != null && entry.lon != null) {
|
if (entry.type === "aprs" && entry.visibleInHistoryWindow) {
|
||||||
_aprsAddMarkerToMap(call, entry);
|
ensureAprsMarker(call, entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const [key, entry] of locatorMarkers) {
|
for (const [key, entry] of locatorMarkers) {
|
||||||
@@ -5614,10 +5727,8 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod
|
|||||||
};
|
};
|
||||||
stationMarkers.set(call, entry);
|
stationMarkers.set(call, entry);
|
||||||
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
|
||||||
if (aprsMap) {
|
if (entry.visibleInHistoryWindow) ensureAprsMarker(call, entry);
|
||||||
_aprsAddMarkerToMap(call, entry);
|
if (aprsMap) scheduleDecodeMapMaintenance();
|
||||||
scheduleDecodeMapMaintenance();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5724,32 +5835,36 @@ window.aisMapAddVessel = function(msg) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!aprsMap) return;
|
|
||||||
const marker = createAisMarker(msg.lat, msg.lon, msg).addTo(aprsMap).bindPopup(popupHtml);
|
|
||||||
marker.__trxType = "ais";
|
|
||||||
marker._aisMmsi = key;
|
|
||||||
mapMarkers.add(marker);
|
|
||||||
aisMarkers.set(key, {
|
aisMarkers.set(key, {
|
||||||
marker,
|
marker: null,
|
||||||
track: null,
|
track: null,
|
||||||
trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }],
|
trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }],
|
||||||
trackPoints: [nextPoint],
|
trackPoints: [nextPoint],
|
||||||
msg,
|
msg,
|
||||||
});
|
});
|
||||||
pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs());
|
pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs());
|
||||||
|
if (aisMarkers.get(key)?.visibleInHistoryWindow) ensureAisMarker(key, aisMarkers.get(key));
|
||||||
scheduleDecodeMapMaintenance();
|
scheduleDecodeMapMaintenance();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.vdesMapAddPoint = function(msg) {
|
window.vdesMapAddPoint = function(msg) {
|
||||||
if (msg == null || msg.lat == null || msg.lon == null) return;
|
if (msg == null || msg.lat == null || msg.lon == null) return;
|
||||||
if (Number(msg?._tsMs) < mapHistoryCutoffMs()) return;
|
|
||||||
const key = vdesMarkerKey(msg);
|
const key = vdesMarkerKey(msg);
|
||||||
if (!key) return;
|
if (!key) return;
|
||||||
if (!aprsMap) initAprsMap();
|
if (!aprsMap) initAprsMap();
|
||||||
const popupHtml = buildVdesPopupHtml(msg);
|
const popupHtml = buildVdesPopupHtml(msg);
|
||||||
|
const visible = Number.isFinite(Number(msg?._tsMs))
|
||||||
|
&& Number(msg._tsMs) >= mapHistoryCutoffMs();
|
||||||
const existing = vdesMarkers.get(key);
|
const existing = vdesMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.msg = msg;
|
existing.msg = msg;
|
||||||
|
existing.visibleInHistoryWindow = visible;
|
||||||
|
if (!visible) {
|
||||||
|
setRetainedMapMarkerVisible(existing.marker, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ensureVdesMarker(key, existing);
|
||||||
|
setRetainedMapMarkerVisible(existing.marker, true);
|
||||||
if (existing.marker) {
|
if (existing.marker) {
|
||||||
existing.marker.setLatLng([msg.lat, msg.lon]);
|
existing.marker.setLatLng([msg.lat, msg.lon]);
|
||||||
existing.marker.setPopupContent(popupHtml);
|
existing.marker.setPopupContent(popupHtml);
|
||||||
@@ -5759,23 +5874,15 @@ window.vdesMapAddPoint = function(msg) {
|
|||||||
const entry = {
|
const entry = {
|
||||||
marker: null,
|
marker: null,
|
||||||
msg,
|
msg,
|
||||||
|
visibleInHistoryWindow: visible,
|
||||||
};
|
};
|
||||||
vdesMarkers.set(key, entry);
|
vdesMarkers.set(key, entry);
|
||||||
if (Number(msg._tsMs) < mapHistoryCutoffMs()) {
|
if (!visible) return;
|
||||||
vdesMarkers.delete(key);
|
ensureVdesMarker(key, entry);
|
||||||
return;
|
setRetainedMapMarkerVisible(entry.marker, true);
|
||||||
|
if (entry.marker) {
|
||||||
|
entry.marker.setPopupContent(popupHtml);
|
||||||
}
|
}
|
||||||
if (!aprsMap) return;
|
|
||||||
const marker = L.circleMarker([msg.lat, msg.lon], {
|
|
||||||
radius: 5,
|
|
||||||
color: "#5c394f",
|
|
||||||
fillColor: "#c46392",
|
|
||||||
fillOpacity: 0.82,
|
|
||||||
}).addTo(aprsMap).bindPopup(popupHtml);
|
|
||||||
marker.__trxType = "vdes";
|
|
||||||
marker._vdesKey = key;
|
|
||||||
entry.marker = marker;
|
|
||||||
mapMarkers.add(marker);
|
|
||||||
scheduleDecodeMapMaintenance();
|
scheduleDecodeMapMaintenance();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -5812,7 +5919,10 @@ function applyMapFilter() {
|
|||||||
if (!aprsMap) return;
|
if (!aprsMap) return;
|
||||||
mapMarkers.forEach((marker) => {
|
mapMarkers.forEach((marker) => {
|
||||||
const type = marker.__trxType;
|
const type = marker.__trxType;
|
||||||
const visible = markerPassesSearchFilter(marker) && markerPassesLocatorFilters(marker) && (
|
const visible = marker.__trxHistoryVisible !== false
|
||||||
|
&& markerPassesSearchFilter(marker)
|
||||||
|
&& markerPassesLocatorFilters(marker)
|
||||||
|
&& (
|
||||||
(type === "bookmark" && mapFilter.bookmark) ||
|
(type === "bookmark" && mapFilter.bookmark) ||
|
||||||
(type === "ais" && mapFilter.ais) ||
|
(type === "ais" && mapFilter.ais) ||
|
||||||
(type === "vdes" && mapFilter.vdes) ||
|
(type === "vdes" && mapFilter.vdes) ||
|
||||||
@@ -6028,7 +6138,7 @@ function renderMapQsoSummary() {
|
|||||||
|
|
||||||
const pair = document.createElement("div");
|
const pair = document.createElement("div");
|
||||||
pair.className = "map-qso-card-pair";
|
pair.className = "map-qso-card-pair";
|
||||||
pair.textContent = `${entry.source || "Unknown"} -> ${entry.target || "Unknown"}`;
|
pair.textContent = `${entry.source || "Unknown"} <-> ${entry.target || "Unknown"}`;
|
||||||
body.appendChild(pair);
|
body.appendChild(pair);
|
||||||
|
|
||||||
const meta = document.createElement("div");
|
const meta = document.createElement("div");
|
||||||
@@ -6059,7 +6169,7 @@ function renderMapQsoSummary() {
|
|||||||
|
|
||||||
const grids = document.createElement("div");
|
const grids = document.createElement("div");
|
||||||
grids.className = "map-qso-card-grids";
|
grids.className = "map-qso-card-grids";
|
||||||
grids.textContent = `${entry.sourceGrid || "--"} -> ${entry.targetGrid || "--"}`;
|
grids.textContent = `${entry.sourceGrid || "--"} <-> ${entry.targetGrid || "--"}`;
|
||||||
body.appendChild(grids);
|
body.appendChild(grids);
|
||||||
|
|
||||||
card.appendChild(head);
|
card.appendChild(head);
|
||||||
@@ -6197,46 +6307,38 @@ window.ft8MapAddLocator = function(message, grids, type = "ft8", station = null,
|
|||||||
freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null,
|
freq_hz: Number.isFinite(details?.freq_hz) ? Number(details.freq_hz) : null,
|
||||||
message: String(details?.message || message || "").trim() || null,
|
message: String(details?.message || message || "").trim() || null,
|
||||||
};
|
};
|
||||||
if (Number(detailEntry.ts_ms) < mapHistoryCutoffMs()) continue;
|
|
||||||
const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
|
const detailKey = detailStationId || `${targetId || "decode"}:${detailEntry.message || "decode"}:${detailEntry.ts_ms || Date.now()}`;
|
||||||
const key = `${markerType}:${grid}`;
|
const key = `${markerType}:${grid}`;
|
||||||
const existing = locatorMarkers.get(key);
|
const existing = locatorMarkers.get(key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.grid = grid;
|
existing.grid = grid;
|
||||||
if (detailStationId) existing.stations.add(detailStationId);
|
if (!(existing.allStationDetails instanceof Map)) {
|
||||||
if (!(existing.stationDetails instanceof Map)) existing.stationDetails = new Map();
|
existing.allStationDetails = existing.stationDetails instanceof Map
|
||||||
existing.stationDetails.set(detailKey, { ...detailEntry });
|
? new Map(existing.stationDetails)
|
||||||
|
: new Map();
|
||||||
|
}
|
||||||
|
existing.allStationDetails.set(detailKey, { ...detailEntry });
|
||||||
existing.sourceType = markerType;
|
existing.sourceType = markerType;
|
||||||
existing.bandMeta = collectBandMeta(
|
pruneLocatorEntry(key, existing, mapHistoryCutoffMs());
|
||||||
Array.from(existing.stationDetails.values()).map((detail) => Number(detail?.freq_hz))
|
if (existing.marker) sendLocatorOverlayToBack(existing.marker);
|
||||||
);
|
|
||||||
const count = Math.max(existing.stationDetails.size, existing.stations.size || 0, 1);
|
|
||||||
const tooltipHtml = buildDecodeLocatorTooltipHtml(grid, existing, markerType);
|
|
||||||
existing.marker.setStyle(locatorStyleForEntry(existing, count));
|
|
||||||
existing.marker.setPopupContent(tooltipHtml);
|
|
||||||
sendLocatorOverlayToBack(existing.marker);
|
|
||||||
assignLocatorMarkerMeta(existing.marker, existing.sourceType, existing.bandMeta);
|
|
||||||
scheduleDecodeMapMaintenance();
|
scheduleDecodeMapMaintenance();
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stations = new Set();
|
const allStationDetails = new Map();
|
||||||
if (detailStationId) stations.add(detailStationId);
|
allStationDetails.set(detailKey, { ...detailEntry });
|
||||||
const stationDetails = new Map();
|
const entry = {
|
||||||
stationDetails.set(detailKey, { ...detailEntry });
|
marker: null,
|
||||||
const bandMeta = collectBandMeta(
|
grid,
|
||||||
Array.from(stationDetails.values()).map((detail) => Number(detail?.freq_hz))
|
stations: new Set(),
|
||||||
);
|
stationDetails: new Map(),
|
||||||
const count = Math.max(stationDetails.size, stations.size || 0, 1);
|
allStationDetails,
|
||||||
const tooltipHtml = buildDecodeLocatorTooltipHtml(grid, { stations, stationDetails }, markerType);
|
sourceType: markerType,
|
||||||
const marker = L.rectangle(bounds, locatorStyleForEntry({ sourceType: markerType, bandMeta }, count))
|
bandMeta: new Map(),
|
||||||
.addTo(aprsMap)
|
};
|
||||||
.bindPopup(tooltipHtml);
|
locatorMarkers.set(key, entry);
|
||||||
marker.__trxType = markerType;
|
pruneLocatorEntry(key, entry, mapHistoryCutoffMs());
|
||||||
sendLocatorOverlayToBack(marker);
|
if (entry.marker) sendLocatorOverlayToBack(entry.marker);
|
||||||
assignLocatorMarkerMeta(marker, markerType, bandMeta);
|
|
||||||
locatorMarkers.set(key, { marker, grid, stations, stationDetails, sourceType: markerType, bandMeta });
|
|
||||||
mapMarkers.add(marker);
|
|
||||||
}
|
}
|
||||||
scheduleDecodeMapMaintenance();
|
scheduleDecodeMapMaintenance();
|
||||||
};
|
};
|
||||||
@@ -7003,7 +7105,7 @@ function scheduleDecodeHistoryDrainStep(callback) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function drainDecodeHistory(buffer, index, onDone) {
|
function drainDecodeHistory(buffer, index, onDone, onProgress) {
|
||||||
const startedAt = typeof performance !== "undefined" && typeof performance.now === "function"
|
const startedAt = typeof performance !== "undefined" && typeof performance.now === "function"
|
||||||
? performance.now()
|
? performance.now()
|
||||||
: 0;
|
: 0;
|
||||||
@@ -7014,8 +7116,11 @@ function drainDecodeHistory(buffer, index, onDone) {
|
|||||||
if (nextIndex - index >= DECODE_HISTORY_MAX_BATCH) break;
|
if (nextIndex - index >= DECODE_HISTORY_MAX_BATCH) break;
|
||||||
if (startedAt > 0 && (performance.now() - startedAt) >= DECODE_HISTORY_SLICE_BUDGET_MS) break;
|
if (startedAt > 0 && (performance.now() - startedAt) >= DECODE_HISTORY_SLICE_BUDGET_MS) break;
|
||||||
}
|
}
|
||||||
|
if (typeof onProgress === "function") {
|
||||||
|
onProgress(nextIndex, buffer.length);
|
||||||
|
}
|
||||||
if (nextIndex < buffer.length) {
|
if (nextIndex < buffer.length) {
|
||||||
scheduleDecodeHistoryDrainStep(() => drainDecodeHistory(buffer, nextIndex, onDone));
|
scheduleDecodeHistoryDrainStep(() => drainDecodeHistory(buffer, nextIndex, onDone, onProgress));
|
||||||
} else if (typeof onDone === "function") {
|
} else if (typeof onDone === "function") {
|
||||||
onDone();
|
onDone();
|
||||||
}
|
}
|
||||||
@@ -7036,6 +7141,7 @@ function connectDecode() {
|
|||||||
const liveBuffer = [];
|
const liveBuffer = [];
|
||||||
function flushLiveBuffer() {
|
function flushLiveBuffer() {
|
||||||
historySettled = true;
|
historySettled = true;
|
||||||
|
setDecodeHistoryOverlayVisible(false);
|
||||||
for (const msg of liveBuffer) {
|
for (const msg of liveBuffer) {
|
||||||
try { dispatchDecodeMessage(msg); } catch (_) {}
|
try { dispatchDecodeMessage(msg); } catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -7043,6 +7149,7 @@ function connectDecode() {
|
|||||||
}
|
}
|
||||||
// Safety valve: if the history fetch hangs, unblock after 8 s.
|
// Safety valve: if the history fetch hangs, unblock after 8 s.
|
||||||
const historyTimeout = setTimeout(() => { if (!historySettled) flushLiveBuffer(); }, 8000);
|
const historyTimeout = setTimeout(() => { if (!historySettled) flushLiveBuffer(); }, 8000);
|
||||||
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", "Fetching recent decodes from the client buffer");
|
||||||
|
|
||||||
decodeSource = new EventSource("/decode");
|
decodeSource = new EventSource("/decode");
|
||||||
decodeSource.onopen = () => {
|
decodeSource.onopen = () => {
|
||||||
@@ -7077,7 +7184,19 @@ function connectDecode() {
|
|||||||
}).then((msgs) => {
|
}).then((msgs) => {
|
||||||
clearTimeout(historyTimeout);
|
clearTimeout(historyTimeout);
|
||||||
if (Array.isArray(msgs) && msgs.length > 0) {
|
if (Array.isArray(msgs) && msgs.length > 0) {
|
||||||
drainDecodeHistory(msgs, 0, flushLiveBuffer);
|
setDecodeHistoryOverlayVisible(true, "Loading decode history…", `Replaying 0 / ${msgs.length} decoded messages`);
|
||||||
|
drainDecodeHistory(
|
||||||
|
msgs,
|
||||||
|
0,
|
||||||
|
flushLiveBuffer,
|
||||||
|
(processed, total) => {
|
||||||
|
setDecodeHistoryOverlayVisible(
|
||||||
|
true,
|
||||||
|
"Loading decode history…",
|
||||||
|
`Replaying ${processed} / ${total} decoded messages`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
flushLiveBuffer();
|
flushLiveBuffer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -897,6 +897,12 @@
|
|||||||
<div class="hint" id="power-hint">Connecting…</div>
|
<div class="hint" id="power-hint">Connecting…</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="decode-history-overlay" class="decode-history-overlay is-hidden" aria-live="polite" aria-atomic="true">
|
||||||
|
<div class="decode-history-overlay-card">
|
||||||
|
<div id="decode-history-overlay-title" class="decode-history-overlay-title">Loading decode history…</div>
|
||||||
|
<div id="decode-history-overlay-sub" class="decode-history-overlay-sub">Preparing recent decodes for the UI</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script src="/webgl-renderer.js"></script>
|
<script src="/webgl-renderer.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
<script src="/ais.js"></script>
|
<script src="/ais.js"></script>
|
||||||
|
|||||||
@@ -1223,6 +1223,46 @@ 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 { 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.active { border-bottom-color: var(--accent-green); color: var(--accent-green); font-weight: 600; }
|
||||||
.sub-tab:hover:not(.active) { color: var(--text); }
|
.sub-tab:hover:not(.active) { color: var(--text); }
|
||||||
|
.decode-history-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 9500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 1.2rem;
|
||||||
|
background: color-mix(in srgb, var(--bg) 36%, transparent);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
-webkit-backdrop-filter: blur(6px);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transition: opacity 140ms ease, visibility 140ms ease;
|
||||||
|
}
|
||||||
|
.decode-history-overlay.is-hidden {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
.decode-history-overlay-card {
|
||||||
|
min-width: min(26rem, calc(100vw - 2.4rem));
|
||||||
|
max-width: min(30rem, calc(100vw - 2.4rem));
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 0.9rem;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--border-light) 72%, transparent);
|
||||||
|
background: color-mix(in srgb, var(--card-bg) 88%, transparent);
|
||||||
|
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.22);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.decode-history-overlay-title {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text-heading);
|
||||||
|
}
|
||||||
|
.decode-history-overlay-sub {
|
||||||
|
margin-top: 0.24rem;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
#tab-map {
|
#tab-map {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user