[fix](trx-frontend-http): add map history limit filter

Add a map filter-panel history picker with 15 minute through 24 hour retention options and prune dynamic APRS, AIS, VDES, FT8, and WSPR overlays to the selected age window.

Verification: node --check 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:
2026-03-14 16:41:14 +01:00
parent daedd91829
commit 3b52fdf232
3 changed files with 258 additions and 59 deletions
@@ -3779,6 +3779,7 @@ let mapFullscreenListenerBound = false;
let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false; let mapP2pRadioPathsEnabled = loadSetting("mapP2pRadioPathsEnabled", true) !== false;
let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false; let mapDecodeContactPathsEnabled = loadSetting("mapDecodeContactPathsEnabled", true) !== false;
let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false; let mapOverlayPanelVisible = loadSetting("mapOverlayPanelVisible", true) !== false;
const MAP_HISTORY_LIMIT_OPTIONS = [15, 30, 60, 180, 360, 720, 1440];
const stationMarkers = new Map(); const stationMarkers = new Map();
const locatorMarkers = new Map(); const locatorMarkers = new Map();
const decodeContactPaths = new Map(); const decodeContactPaths = new Map();
@@ -3787,6 +3788,10 @@ const DEFAULT_MAP_SOURCE_FILTER = { ais: true, vdes: true, aprs: true, bookmark:
const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER }; const mapFilter = { ...DEFAULT_MAP_SOURCE_FILTER };
const mapLocatorFilter = { phase: "band", bands: new Set() }; const mapLocatorFilter = { phase: "band", bands: new Set() };
let mapSearchFilter = ""; let mapSearchFilter = "";
let mapHistoryPruneTimer = null;
let mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(
Number(loadSetting("mapHistoryLimitMinutes", 1440))
);
const APRS_TRACK_MAX_POINTS = 64; const APRS_TRACK_MAX_POINTS = 64;
const AIS_TRACK_MAX_POINTS = 64; const AIS_TRACK_MAX_POINTS = 64;
const aisMarkers = new Map(); const aisMarkers = new Map();
@@ -3832,6 +3837,182 @@ function normalizeLocatorFreqHz(hz) {
return hz; return hz;
} }
function normalizeMapHistoryLimitMinutes(value) {
const minutes = Math.round(Number(value));
return MAP_HISTORY_LIMIT_OPTIONS.includes(minutes) ? minutes : 1440;
}
function mapHistoryCutoffMs() {
return Date.now() - (mapHistoryLimitMinutes * 60 * 1000);
}
function trimTrackHistory(history, cutoffMs, maxPoints) {
const list = Array.isArray(history) ? history : [];
const trimmed = list.filter((point) => Number(point?.tsMs) >= cutoffMs);
if (trimmed.length > maxPoints) {
trimmed.splice(0, trimmed.length - maxPoints);
}
return trimmed;
}
function refreshAprsTrack(call, entry) {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
if (entry.track) {
entry.track.remove();
entry.track = null;
}
return;
}
if (entry.track) {
entry.track.setLatLngs(entry.trackPoints);
return;
}
const track = L.polyline(entry.trackPoints, {
color: "#f0be4d",
weight: 2,
opacity: 0.72,
lineCap: "round",
lineJoin: "round",
interactive: false,
});
track.__trxType = "aprs";
track._aprsCall = call;
entry.track = track;
}
function refreshAisTrack(mmsi, entry) {
if (!entry) return;
if (!Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) {
if (entry.track) {
entry.track.remove();
entry.track = null;
}
return;
}
if (entry.track) {
entry.track.setLatLngs(entry.trackPoints);
return;
}
const track = L.polyline(entry.trackPoints, {
color: getAisAccentColor(),
weight: 2,
opacity: 0.68,
lineCap: "round",
lineJoin: "round",
interactive: false,
dashArray: "5 4",
});
track.__trxType = "ais";
track._aisMmsi = mmsi;
entry.track = track;
}
function removeMapMarker(marker) {
if (!marker) return;
if (marker === selectedLocatorMarker) {
setSelectedLocatorMarker(null);
clearMapRadioPath();
}
if (aprsMap && aprsMap.hasLayer(marker)) marker.removeFrom(aprsMap);
mapMarkers.delete(marker);
}
function pruneAprsEntry(call, entry, cutoffMs) {
const pktTsMs = Number(entry?.pkt?._tsMs);
if (!Number.isFinite(pktTsMs) || pktTsMs < cutoffMs) {
if (selectedAprsTrackCall && String(selectedAprsTrackCall) === String(call)) {
selectedAprsTrackCall = null;
}
if (entry?.track) {
entry.track.remove();
entry.track = null;
}
removeMapMarker(entry?.marker);
stationMarkers.delete(call);
return false;
}
entry.trackHistory = trimTrackHistory(entry.trackHistory, cutoffMs, APRS_TRACK_MAX_POINTS);
entry.trackPoints = entry.trackHistory.map((point) => [point.lat, point.lon]);
refreshAprsTrack(call, entry);
return true;
}
function pruneAisEntry(key, entry, cutoffMs) {
const msgTsMs = Number(entry?.msg?._tsMs);
if (!Number.isFinite(msgTsMs) || msgTsMs < cutoffMs) {
if (selectedAisTrackMmsi && String(selectedAisTrackMmsi) === String(key)) {
selectedAisTrackMmsi = null;
}
if (entry?.track) {
entry.track.remove();
entry.track = null;
}
removeMapMarker(entry?.marker);
aisMarkers.delete(key);
return false;
}
entry.trackHistory = trimTrackHistory(entry.trackHistory, cutoffMs, AIS_TRACK_MAX_POINTS);
entry.trackPoints = entry.trackHistory.map((point) => [point.lat, point.lon]);
refreshAisTrack(key, entry);
return true;
}
function pruneLocatorEntry(key, entry, cutoffMs) {
if (!entry || (entry.sourceType !== "ft8" && entry.sourceType !== "wspr")) return true;
const nextDetails = new Map();
for (const [detailKey, detail] of entry.stationDetails instanceof Map ? entry.stationDetails.entries() : []) {
const tsMs = Number(detail?.ts_ms);
if (Number.isFinite(tsMs) && tsMs >= cutoffMs) {
nextDetails.set(detailKey, detail);
}
}
if (nextDetails.size === 0) {
removeMapMarker(entry.marker);
locatorMarkers.delete(key);
return false;
}
const nextStations = new Set();
for (const detail of nextDetails.values()) {
const source = String(detail?.source || detail?.station || "").trim().toUpperCase();
if (source) nextStations.add(source);
}
entry.stationDetails = nextDetails;
entry.stations = nextStations;
entry.bandMeta = collectBandMeta(
Array.from(nextDetails.values()).map((detail) => Number(detail?.freq_hz))
);
const count = Math.max(nextDetails.size, nextStations.size || 0, 1);
if (entry.marker) {
entry.marker.setStyle(locatorStyleForEntry(entry, count));
entry.marker.setPopupContent(buildDecodeLocatorTooltipHtml(entry.grid, entry, entry.sourceType));
assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
}
return true;
}
function pruneMapHistory() {
const cutoffMs = mapHistoryCutoffMs();
for (const [call, entry] of Array.from(stationMarkers.entries())) {
pruneAprsEntry(call, entry, cutoffMs);
}
for (const [key, entry] of Array.from(aisMarkers.entries())) {
pruneAisEntry(key, entry, cutoffMs);
}
for (const [key, entry] of Array.from(vdesMarkers.entries())) {
const tsMs = Number(entry?.msg?._tsMs);
if (Number.isFinite(tsMs) && tsMs >= cutoffMs) continue;
removeMapMarker(entry?.marker);
vdesMarkers.delete(key);
}
for (const [key, entry] of Array.from(locatorMarkers.entries())) {
pruneLocatorEntry(key, entry, cutoffMs);
}
rebuildDecodeContactPaths();
rebuildMapLocatorFilters();
applyMapFilter();
}
function locatorSourceLabel(type) { function locatorSourceLabel(type) {
if (type === "bookmark") return "Bookmarks"; if (type === "bookmark") return "Bookmarks";
if (type === "wspr") return "WSPR"; if (type === "wspr") return "WSPR";
@@ -4808,7 +4989,7 @@ function initAprsMap() {
const entry = stationMarkers.get(marker._aprsCall); const entry = stationMarkers.get(marker._aprsCall);
if (!entry) return; if (!entry) return;
e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt)); e.popup.setContent(buildAprsPopupHtml(marker._aprsCall, ll.lat, ll.lng, entry.info || "", entry.pkt));
ensureAprsTrack(String(marker._aprsCall), entry); refreshAprsTrack(String(marker._aprsCall), entry);
if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) { if (entry.track && aprsMap && mapFilter.aprs && !aprsMap.hasLayer(entry.track)) {
entry.track.addTo(aprsMap); entry.track.addTo(aprsMap);
} }
@@ -4822,7 +5003,7 @@ function initAprsMap() {
const entry = aisMarkers.get(String(marker._aisMmsi)); const entry = aisMarkers.get(String(marker._aisMmsi));
if (!entry || !entry.msg) return; if (!entry || !entry.msg) return;
e.popup.setContent(buildAisPopupHtml(entry.msg)); e.popup.setContent(buildAisPopupHtml(entry.msg));
ensureAisTrack(String(marker._aisMmsi), entry); refreshAisTrack(String(marker._aisMmsi), entry);
selectedAisTrackMmsi = String(marker._aisMmsi); selectedAisTrackMmsi = String(marker._aisMmsi);
syncSelectedAisTrackVisibility(); syncSelectedAisTrackVisibility();
setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path"); setMapRadioPathTo(ll.lat, ll.lng, mapSourceColor("ais"), "aprs-radio-path");
@@ -4885,6 +5066,7 @@ function initAprsMap() {
assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta); assignLocatorMarkerMeta(entry.marker, entry.sourceType, entry.bandMeta);
mapMarkers.add(entry.marker); mapMarkers.add(entry.marker);
} }
pruneMapHistory();
rebuildDecodeContactPaths(); rebuildDecodeContactPaths();
rebuildMapLocatorFilters(); rebuildMapLocatorFilters();
applyMapFilter(); applyMapFilter();
@@ -4892,6 +5074,7 @@ function initAprsMap() {
const locatorPhaseEl = document.getElementById("map-locator-phase"); const locatorPhaseEl = document.getElementById("map-locator-phase");
const locatorChoiceEl = document.getElementById("map-locator-choice-filter"); const locatorChoiceEl = document.getElementById("map-locator-choice-filter");
const mapSearchEl = document.getElementById("map-search-filter"); const mapSearchEl = document.getElementById("map-search-filter");
const mapHistoryLimitEl = document.getElementById("map-history-limit");
const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle"); const mapP2pPathsToggleEl = document.getElementById("map-p2p-paths-toggle");
const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle"); const mapContactPathsToggleEl = document.getElementById("map-contact-paths-toggle");
const fullscreenBtn = document.getElementById("map-fullscreen-btn"); const fullscreenBtn = document.getElementById("map-fullscreen-btn");
@@ -4949,6 +5132,15 @@ function initAprsMap() {
applyMapFilter(); applyMapFilter();
}); });
} }
if (mapHistoryLimitEl) {
mapHistoryLimitEl.value = String(mapHistoryLimitMinutes);
mapHistoryLimitEl.addEventListener("change", () => {
mapHistoryLimitMinutes = normalizeMapHistoryLimitMinutes(Number(mapHistoryLimitEl.value));
mapHistoryLimitEl.value = String(mapHistoryLimitMinutes);
saveSetting("mapHistoryLimitMinutes", mapHistoryLimitMinutes);
pruneMapHistory();
});
}
if (mapP2pPathsToggleEl) { if (mapP2pPathsToggleEl) {
updateMapP2pPathsToggle(); updateMapP2pPathsToggle();
mapP2pPathsToggleEl.addEventListener("click", () => { mapP2pPathsToggleEl.addEventListener("click", () => {
@@ -4992,6 +5184,11 @@ function initAprsMap() {
document.addEventListener("webkitfullscreenchange", onFullscreenChange); document.addEventListener("webkitfullscreenchange", onFullscreenChange);
mapFullscreenListenerBound = true; mapFullscreenListenerBound = true;
} }
if (!mapHistoryPruneTimer) {
mapHistoryPruneTimer = setInterval(() => {
pruneMapHistory();
}, 60 * 1000);
}
rebuildMapLocatorFilters(); rebuildMapLocatorFilters();
} }
@@ -5336,12 +5533,20 @@ function buildVdesPopupHtml(msg) {
function aprsPositionsEqual(a, b) { function aprsPositionsEqual(a, b) {
if (!a || !b) return false; if (!a || !b) return false;
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001; const aLat = Array.isArray(a) ? a[0] : a.lat;
const aLon = Array.isArray(a) ? a[1] : a.lon;
const bLat = Array.isArray(b) ? b[0] : b.lat;
const bLon = Array.isArray(b) ? b[1] : b.lon;
return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001;
} }
function aisPositionsEqual(a, b) { function aisPositionsEqual(a, b) {
if (!a || !b) return false; if (!a || !b) return false;
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001; const aLat = Array.isArray(a) ? a[0] : a.lat;
const aLon = Array.isArray(a) ? a[1] : a.lon;
const bLat = Array.isArray(b) ? b[0] : b.lat;
const bLon = Array.isArray(b) ? b[1] : b.lon;
return Math.abs(aLat - bLat) < 0.000001 && Math.abs(aLon - bLon) < 0.000001;
} }
function vdesMarkerKey(msg) { function vdesMarkerKey(msg) {
@@ -5353,27 +5558,8 @@ function vdesMarkerKey(msg) {
return null; return null;
} }
function ensureAprsTrack(call, entry) {
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
if (entry.track) {
entry.track.setLatLngs(entry.trackPoints);
return;
}
const track = L.polyline(entry.trackPoints, {
color: "#f0be4d",
weight: 2,
opacity: 0.72,
lineCap: "round",
lineJoin: "round",
interactive: false,
});
track.__trxType = "aprs";
track._aprsCall = call;
entry.track = track;
}
function _aprsAddMarkerToMap(call, entry) { function _aprsAddMarkerToMap(call, entry) {
ensureAprsTrack(call, entry); refreshAprsTrack(call, entry);
const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode); const icon = aprsSymbolIcon(entry.symbolTable, entry.symbolCode);
const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt); const popupContent = buildAprsPopupHtml(call, entry.lat, entry.lon, entry.info || "", entry.pkt);
const marker = icon const marker = icon
@@ -5389,24 +5575,23 @@ function _aprsAddMarkerToMap(call, entry) {
window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) { window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCode, pkt) {
const nextPoint = [lat, lon]; const nextPoint = [lat, lon];
const tsMs = Number.isFinite(pkt?._tsMs) ? Number(pkt._tsMs) : Date.now();
const existing = stationMarkers.get(call); const existing = stationMarkers.get(call);
if (existing) { if (existing) {
// Update stored data (preserves original _tsMs if pkt is newer)
existing.pkt = pkt; existing.pkt = pkt;
existing.lat = lat; existing.lat = lat;
existing.lon = lon; existing.lon = lon;
existing.info = info; existing.info = info;
existing.symbolTable = symbolTable; existing.symbolTable = symbolTable;
existing.symbolCode = symbolCode; existing.symbolCode = symbolCode;
if (!Array.isArray(existing.trackPoints)) existing.trackPoints = []; if (!Array.isArray(existing.trackHistory)) existing.trackHistory = [];
const prevPoint = existing.trackPoints[existing.trackPoints.length - 1]; const prevPoint = existing.trackHistory[existing.trackHistory.length - 1];
if (!aprsPositionsEqual(prevPoint, nextPoint)) { if (!aprsPositionsEqual(prevPoint, nextPoint)) {
existing.trackPoints.push(nextPoint); existing.trackHistory.push({ lat, lon, tsMs });
if (existing.trackPoints.length > APRS_TRACK_MAX_POINTS) { } else if (prevPoint) {
existing.trackPoints.splice(0, existing.trackPoints.length - APRS_TRACK_MAX_POINTS); prevPoint.tsMs = tsMs;
}
ensureAprsTrack(call, existing);
} }
pruneAprsEntry(call, existing, mapHistoryCutoffMs());
if (aprsMap && existing.marker) { if (aprsMap && existing.marker) {
existing.marker.setLatLng([lat, lon]); existing.marker.setLatLng([lat, lon]);
existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt)); existing.marker.setPopupContent(buildAprsPopupHtml(call, lat, lon, info, pkt));
@@ -5415,6 +5600,7 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod
const entry = { const entry = {
marker: null, marker: null,
track: null, track: null,
trackHistory: [{ lat, lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
type: "aprs", type: "aprs",
pkt, pkt,
@@ -5425,6 +5611,7 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod
symbolCode, symbolCode,
}; };
stationMarkers.set(call, entry); stationMarkers.set(call, entry);
pruneAprsEntry(call, entry, mapHistoryCutoffMs());
if (aprsMap) { if (aprsMap) {
_aprsAddMarkerToMap(call, entry); _aprsAddMarkerToMap(call, entry);
scheduleDecodeMapMaintenance(); scheduleDecodeMapMaintenance();
@@ -5432,26 +5619,6 @@ window.aprsMapAddStation = function(call, lat, lon, info, symbolTable, symbolCod
} }
}; };
function ensureAisTrack(mmsi, entry) {
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
if (entry.track) {
entry.track.setLatLngs(entry.trackPoints);
return;
}
const track = L.polyline(entry.trackPoints, {
color: getAisAccentColor(),
weight: 2,
opacity: 0.68,
lineCap: "round",
lineJoin: "round",
interactive: false,
dashArray: "5 4",
});
track.__trxType = "ais";
track._aisMmsi = mmsi;
entry.track = track;
}
function syncSelectedAisTrackVisibility() { function syncSelectedAisTrackVisibility() {
if (!aprsMap) return; if (!aprsMap) return;
const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null; const selectedKey = selectedAisTrackMmsi ? String(selectedAisTrackMmsi) : null;
@@ -5538,18 +5705,18 @@ window.aisMapAddVessel = function(msg) {
const key = String(msg.mmsi); const key = String(msg.mmsi);
const popupHtml = buildAisPopupHtml(msg); const popupHtml = buildAisPopupHtml(msg);
const nextPoint = [msg.lat, msg.lon]; const nextPoint = [msg.lat, msg.lon];
const tsMs = Number.isFinite(msg?._tsMs) ? Number(msg._tsMs) : Date.now();
const existing = aisMarkers.get(key); const existing = aisMarkers.get(key);
if (existing) { if (existing) {
existing.msg = msg; existing.msg = msg;
if (!Array.isArray(existing.trackPoints)) existing.trackPoints = []; if (!Array.isArray(existing.trackHistory)) existing.trackHistory = [];
const prevPoint = existing.trackPoints[existing.trackPoints.length - 1]; const prevPoint = existing.trackHistory[existing.trackHistory.length - 1];
if (!aisPositionsEqual(prevPoint, nextPoint)) { if (!aisPositionsEqual(prevPoint, nextPoint)) {
existing.trackPoints.push(nextPoint); existing.trackHistory.push({ lat: msg.lat, lon: msg.lon, tsMs });
if (existing.trackPoints.length > AIS_TRACK_MAX_POINTS) { } else if (prevPoint) {
existing.trackPoints.splice(0, existing.trackPoints.length - AIS_TRACK_MAX_POINTS); prevPoint.tsMs = tsMs;
}
ensureAisTrack(key, existing);
} }
pruneAisEntry(key, existing, mapHistoryCutoffMs());
if (existing.marker) { if (existing.marker) {
updateAisMarker(existing.marker, msg, popupHtml); updateAisMarker(existing.marker, msg, popupHtml);
} }
@@ -5563,14 +5730,17 @@ window.aisMapAddVessel = function(msg) {
aisMarkers.set(key, { aisMarkers.set(key, {
marker, marker,
track: null, track: null,
trackHistory: [{ lat: msg.lat, lon: msg.lon, tsMs }],
trackPoints: [nextPoint], trackPoints: [nextPoint],
msg, msg,
}); });
pruneAisEntry(key, aisMarkers.get(key), mapHistoryCutoffMs());
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();
@@ -5589,6 +5759,10 @@ window.vdesMapAddPoint = function(msg) {
msg, msg,
}; };
vdesMarkers.set(key, entry); vdesMarkers.set(key, entry);
if (Number(msg._tsMs) < mapHistoryCutoffMs()) {
vdesMarkers.delete(key);
return;
}
if (!aprsMap) return; if (!aprsMap) return;
const marker = L.circleMarker([msg.lat, msg.lon], { const marker = L.circleMarker([msg.lat, msg.lon], {
radius: 5, radius: 5,
@@ -5930,6 +6104,7 @@ 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);
@@ -689,6 +689,18 @@
<span class="map-locator-filter-label">Search</span> <span class="map-locator-filter-label">Search</span>
<input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." /> <input type="text" id="map-search-filter" class="map-search-input" placeholder="Callsign, MMSI, locator, message..." />
</div> </div>
<div class="map-locator-filter-group">
<span class="map-locator-filter-label">History</span>
<select id="map-history-limit" class="map-history-select" aria-label="Map history limit">
<option value="15">15 min</option>
<option value="30">30 min</option>
<option value="60">1 hr</option>
<option value="180">3 hrs</option>
<option value="360">6 hrs</option>
<option value="720">12 hrs</option>
<option value="1440">24 hrs</option>
</select>
</div>
<div class="map-locator-filter-group"> <div class="map-locator-filter-group">
<span class="map-locator-filter-label">Paths</span> <span class="map-locator-filter-label">Paths</span>
<div class="map-locator-phase-row"> <div class="map-locator-phase-row">
@@ -2005,6 +2005,18 @@ body.map-fake-fullscreen-active {
.map-search-input::placeholder { .map-search-input::placeholder {
color: color-mix(in srgb, var(--text-muted) 92%, transparent); color: color-mix(in srgb, var(--text-muted) 92%, transparent);
} }
.map-history-select {
flex: 1 1 10rem;
width: 100%;
max-width: 12rem;
min-height: 1.95rem;
padding: 0.3rem 0.55rem;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--border-light) 76%, transparent);
background: color-mix(in srgb, var(--card-bg) 72%, transparent);
color: var(--text);
font-size: 0.82rem;
}
.rds-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1rem; align-items: baseline; margin-bottom: 1rem; } .rds-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1rem; align-items: baseline; margin-bottom: 1rem; }
.rds-field { display: contents; } .rds-field { display: contents; }