[feat](trx-frontend): improve AIS decode and decoder views
Improve the AIS decoder timing recovery, add AIS vessel linking and map trails, and make the AIS/APRS decoder panels behave like mode-bound views with full-height history panes. Co-authored-by: OpenAI Codex <codex@openai.com> Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
@@ -41,6 +41,8 @@ pub struct GeneralConfig {
|
||||
pub website_url: Option<String>,
|
||||
/// Optional website name to use as the web UI header title label.
|
||||
pub website_name: Option<String>,
|
||||
/// Optional base URL used to link AIS vessel names as `<base><mmsi>`.
|
||||
pub ais_vessel_url_base: Option<String>,
|
||||
/// Log level (trace, debug, info, warn, error)
|
||||
pub log_level: Option<String>,
|
||||
}
|
||||
@@ -51,6 +53,7 @@ impl Default for GeneralConfig {
|
||||
callsign: Some("N0CALL".to_string()),
|
||||
website_url: None,
|
||||
website_name: None,
|
||||
ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()),
|
||||
log_level: None,
|
||||
}
|
||||
}
|
||||
@@ -356,6 +359,11 @@ impl ClientConfig {
|
||||
return Err("[general].website_name must not be empty when set".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(url) = &self.general.ais_vessel_url_base {
|
||||
if url.trim().is_empty() {
|
||||
return Err("[general].ais_vessel_url_base must not be empty when set".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if self.frontends.http.enabled && self.frontends.http.port == 0 {
|
||||
return Err("[frontends.http].port must be > 0 when enabled".to_string());
|
||||
@@ -459,6 +467,7 @@ impl ClientConfig {
|
||||
callsign: Some("N0CALL".to_string()),
|
||||
website_url: Some("https://haxx.space".to_string()),
|
||||
website_name: Some("haxx.space".to_string()),
|
||||
ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()),
|
||||
log_level: Some("info".to_string()),
|
||||
},
|
||||
remote: RemoteConfig {
|
||||
@@ -586,6 +595,7 @@ mod tests {
|
||||
assert!(config.remote.url.is_none());
|
||||
assert!(config.general.website_url.is_none());
|
||||
assert!(config.general.website_name.is_none());
|
||||
assert!(config.general.ais_vessel_url_base.is_none());
|
||||
assert_eq!(config.remote.poll_interval_ms, 750);
|
||||
assert!(config.frontends.audio.enabled);
|
||||
assert_eq!(config.frontends.audio.server_port, 4531);
|
||||
@@ -602,6 +612,7 @@ mod tests {
|
||||
callsign = "W1AW"
|
||||
website_url = "https://example.com"
|
||||
website_name = "Example"
|
||||
ais_vessel_url_base = "https://example.com/vessel/"
|
||||
|
||||
[remote]
|
||||
url = "192.168.1.100:9000"
|
||||
@@ -623,6 +634,10 @@ spectrum_usable_span_ratio = 0.9
|
||||
assert_eq!(config.general.callsign, Some("W1AW".to_string()));
|
||||
assert_eq!(config.general.website_url, Some("https://example.com".to_string()));
|
||||
assert_eq!(config.general.website_name, Some("Example".to_string()));
|
||||
assert_eq!(
|
||||
config.general.ais_vessel_url_base,
|
||||
Some("https://example.com/vessel/".to_string())
|
||||
);
|
||||
assert_eq!(config.remote.url, Some("192.168.1.100:9000".to_string()));
|
||||
assert_eq!(config.remote.rig_id, Some("hf".to_string()));
|
||||
assert_eq!(config.remote.auth.token, Some("my-token".to_string()));
|
||||
|
||||
@@ -250,6 +250,7 @@ async fn async_init() -> DynResult<AppState> {
|
||||
frontend_runtime.owner_callsign = callsign.clone();
|
||||
frontend_runtime.owner_website_url = cfg.general.website_url.clone();
|
||||
frontend_runtime.owner_website_name = cfg.general.website_name.clone();
|
||||
frontend_runtime.ais_vessel_url_base = cfg.general.ais_vessel_url_base.clone();
|
||||
|
||||
info!(
|
||||
"Starting trx-client (remote: {}, frontends: {})",
|
||||
|
||||
@@ -186,6 +186,8 @@ pub struct FrontendRuntimeContext {
|
||||
pub owner_website_url: Option<String>,
|
||||
/// Optional website name for the web UI header title label.
|
||||
pub owner_website_name: Option<String>,
|
||||
/// Optional base URL used to link AIS vessel names as `<base><mmsi>`.
|
||||
pub ais_vessel_url_base: Option<String>,
|
||||
/// Latest spectrum frame from the active SDR rig; None for non-SDR backends.
|
||||
pub spectrum: Arc<Mutex<SharedSpectrum>>,
|
||||
}
|
||||
@@ -223,6 +225,7 @@ impl FrontendRuntimeContext {
|
||||
owner_callsign: None,
|
||||
owner_website_url: None,
|
||||
owner_website_name: None,
|
||||
ais_vessel_url_base: None,
|
||||
spectrum: Arc::new(Mutex::new(SharedSpectrum::default())),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1723,6 +1723,7 @@ let serverCallsign = null;
|
||||
let ownerCallsign = null;
|
||||
let ownerWebsiteUrl = null;
|
||||
let ownerWebsiteName = null;
|
||||
let aisVesselUrlBase = null;
|
||||
let serverRigs = [];
|
||||
let serverActiveRigId = null;
|
||||
let serverLat = null;
|
||||
@@ -1814,6 +1815,11 @@ function displayLabelFromUrl(url) {
|
||||
}
|
||||
}
|
||||
|
||||
window.buildAisVesselUrl = function(mmsi) {
|
||||
if (!aisVesselUrlBase || !Number.isFinite(Number(mmsi))) return null;
|
||||
return `${aisVesselUrlBase}${String(mmsi)}`;
|
||||
};
|
||||
|
||||
function render(update) {
|
||||
if (!update) return;
|
||||
if (update.server_version) serverVersion = update.server_version;
|
||||
@@ -1828,6 +1834,9 @@ function render(update) {
|
||||
if (typeof update.owner_website_name === "string" && update.owner_website_name.length > 0) {
|
||||
ownerWebsiteName = update.owner_website_name;
|
||||
}
|
||||
if (typeof update.ais_vessel_url_base === "string" && update.ais_vessel_url_base.length > 0) {
|
||||
aisVesselUrlBase = update.ais_vessel_url_base;
|
||||
}
|
||||
if (update.server_latitude != null) serverLat = update.server_latitude;
|
||||
if (update.server_longitude != null) serverLon = update.server_longitude;
|
||||
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
|
||||
@@ -1994,13 +2003,19 @@ function render(update) {
|
||||
const cwStatus = document.getElementById("cw-status");
|
||||
const ft8Status = document.getElementById("ft8-status");
|
||||
const wsprStatus = document.getElementById("wspr-status");
|
||||
if (aisStatus && modeUpper !== "AIS" && aisStatus.textContent === "Receiving") {
|
||||
aisStatus.textContent = "Connected, listening for packets";
|
||||
}
|
||||
setModeBoundDecodeStatus(
|
||||
aisStatus,
|
||||
["AIS"],
|
||||
"Select AIS mode to decode",
|
||||
"Connected, listening for packets",
|
||||
);
|
||||
if (window.updateAisBar) window.updateAisBar();
|
||||
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
|
||||
aprsStatus.textContent = "Connected, listening for packets";
|
||||
}
|
||||
setModeBoundDecodeStatus(
|
||||
aprsStatus,
|
||||
["PKT"],
|
||||
"Select PKT mode to decode",
|
||||
"Connected, listening for packets",
|
||||
);
|
||||
if (window.updateAprsBar) window.updateAprsBar();
|
||||
if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") {
|
||||
cwStatus.textContent = "Connected, listening for packets";
|
||||
@@ -2969,6 +2984,7 @@ const locatorMarkers = new Map();
|
||||
const mapMarkers = new Set();
|
||||
const mapFilter = { ais: true, aprs: true, ft8: true, wspr: true };
|
||||
const APRS_TRACK_MAX_POINTS = 64;
|
||||
const AIS_TRACK_MAX_POINTS = 64;
|
||||
const aisMarkers = new Map();
|
||||
|
||||
window.clearMapMarkersByType = function(type) {
|
||||
@@ -2993,6 +3009,10 @@ window.clearMapMarkersByType = function(type) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.marker);
|
||||
}
|
||||
if (entry && entry.track) {
|
||||
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||
mapMarkers.delete(entry.track);
|
||||
}
|
||||
});
|
||||
aisMarkers.clear();
|
||||
return;
|
||||
@@ -3261,12 +3281,20 @@ function buildAisPopupHtml(msg) {
|
||||
let rows = "";
|
||||
rows += `<tr><td class="aprs-popup-label">MMSI</td><td>${escapeMapHtml(String(msg.mmsi || "--"))}</td></tr>`;
|
||||
rows += `<tr><td class="aprs-popup-label">Type</td><td>${escapeMapHtml(String(msg.message_type || "--"))}</td></tr>`;
|
||||
if (distStr) rows += `<tr><td class="aprs-popup-label">Range</td><td>${distStr} from TRX</td></tr>`;
|
||||
if (msg?.sog_knots != null) rows += `<tr><td class="aprs-popup-label">SOG</td><td>${Number(msg.sog_knots).toFixed(1)} kn</td></tr>`;
|
||||
if (msg?.cog_deg != null) rows += `<tr><td class="aprs-popup-label">COG</td><td>${Number(msg.cog_deg).toFixed(1)}°</td></tr>`;
|
||||
if (msg?.heading_deg != null) rows += `<tr><td class="aprs-popup-label">HDG</td><td>${Number(msg.heading_deg).toFixed(0)}°</td></tr>`;
|
||||
if (msg?.nav_status != null) rows += `<tr><td class="aprs-popup-label">Nav</td><td>${escapeMapHtml(String(msg.nav_status))}</td></tr>`;
|
||||
if (msg?.lat != null && msg?.lon != null) rows += `<tr><td class="aprs-popup-label">Pos</td><td>${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}</td></tr>`;
|
||||
const info = [msg?.vessel_name, msg?.callsign, msg?.destination].filter(Boolean).map(escapeMapHtml).join(" · ");
|
||||
const vesselLabel = escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`);
|
||||
const vesselUrl = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||
const vesselTitle = vesselUrl
|
||||
? `<a class="title-link" href="${escapeMapHtml(vesselUrl)}" target="_blank" rel="noopener">${vesselLabel}</a>`
|
||||
: vesselLabel;
|
||||
return `<div class="aprs-popup">` +
|
||||
`<div class="aprs-popup-call">${escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`)}</div>` +
|
||||
`<div class="aprs-popup-call">${vesselTitle}</div>` +
|
||||
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
|
||||
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
|
||||
(info ? `<div class="aprs-popup-info">${info}</div>` : "") +
|
||||
@@ -3278,6 +3306,11 @@ function aprsPositionsEqual(a, b) {
|
||||
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001;
|
||||
}
|
||||
|
||||
function aisPositionsEqual(a, b) {
|
||||
if (!a || !b) return false;
|
||||
return Math.abs(a[0] - b[0]) < 0.000001 && Math.abs(a[1] - b[1]) < 0.000001;
|
||||
}
|
||||
|
||||
function ensureAprsTrack(call, entry) {
|
||||
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
|
||||
if (entry.track) {
|
||||
@@ -3361,16 +3394,52 @@ 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: "#ff7559",
|
||||
weight: 2,
|
||||
opacity: 0.68,
|
||||
lineCap: "round",
|
||||
lineJoin: "round",
|
||||
interactive: false,
|
||||
dashArray: "5 4",
|
||||
});
|
||||
track.__trxType = "ais";
|
||||
track._aisMmsi = mmsi;
|
||||
entry.track = track;
|
||||
mapMarkers.add(track);
|
||||
if (mapFilter.ais) {
|
||||
track.addTo(aprsMap);
|
||||
}
|
||||
}
|
||||
|
||||
window.aisMapAddVessel = function(msg) {
|
||||
if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
|
||||
if (!aprsMap) initAprsMap();
|
||||
const key = String(msg.mmsi);
|
||||
const popupHtml = buildAisPopupHtml(msg);
|
||||
const nextPoint = [msg.lat, msg.lon];
|
||||
const existing = aisMarkers.get(key);
|
||||
if (existing && existing.marker) {
|
||||
if (existing) {
|
||||
existing.msg = msg;
|
||||
existing.marker.setLatLng([msg.lat, msg.lon]);
|
||||
existing.marker.setPopupContent(popupHtml);
|
||||
if (!Array.isArray(existing.trackPoints)) existing.trackPoints = [];
|
||||
const prevPoint = existing.trackPoints[existing.trackPoints.length - 1];
|
||||
if (!aisPositionsEqual(prevPoint, nextPoint)) {
|
||||
existing.trackPoints.push(nextPoint);
|
||||
if (existing.trackPoints.length > AIS_TRACK_MAX_POINTS) {
|
||||
existing.trackPoints.splice(0, existing.trackPoints.length - AIS_TRACK_MAX_POINTS);
|
||||
}
|
||||
ensureAisTrack(key, existing);
|
||||
}
|
||||
if (existing.marker) {
|
||||
existing.marker.setLatLng([msg.lat, msg.lon]);
|
||||
existing.marker.setPopupContent(popupHtml);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!aprsMap) return;
|
||||
@@ -3381,8 +3450,14 @@ window.aisMapAddVessel = function(msg) {
|
||||
fillOpacity: 0.82,
|
||||
}).addTo(aprsMap).bindPopup(popupHtml);
|
||||
marker.__trxType = "ais";
|
||||
marker._aisMmsi = key;
|
||||
mapMarkers.add(marker);
|
||||
aisMarkers.set(key, { marker, msg });
|
||||
aisMarkers.set(key, {
|
||||
marker,
|
||||
track: null,
|
||||
trackPoints: [nextPoint],
|
||||
msg,
|
||||
});
|
||||
applyMapFilter();
|
||||
};
|
||||
|
||||
@@ -4109,13 +4184,20 @@ document.getElementById("copyright-year").textContent = new Date().getFullYear()
|
||||
// --- Server-side decode SSE ---
|
||||
let decodeSource = null;
|
||||
let decodeConnected = false;
|
||||
function setModeBoundDecodeStatus(el, activeModes, inactiveText, connectedText) {
|
||||
if (!el) return;
|
||||
const modeUpper = (document.getElementById("mode")?.value || "").toUpperCase();
|
||||
const isActiveMode = activeModes.includes(modeUpper);
|
||||
if (el.textContent === "Receiving" && isActiveMode) return;
|
||||
el.textContent = isActiveMode ? connectedText : inactiveText;
|
||||
}
|
||||
function updateDecodeStatus(text) {
|
||||
const ais = document.getElementById("ais-status");
|
||||
const aprs = document.getElementById("aprs-status");
|
||||
const cw = document.getElementById("cw-status");
|
||||
const ft8 = document.getElementById("ft8-status");
|
||||
if (ais && ais.textContent !== "Receiving") ais.textContent = text;
|
||||
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
|
||||
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
|
||||
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
|
||||
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
||||
if (ft8 && ft8.textContent !== "Receiving") ft8.textContent = text;
|
||||
}
|
||||
|
||||
@@ -49,6 +49,13 @@ function aisDisplayName(msg) {
|
||||
return msg.vessel_name || msg.callsign || `MMSI ${msg.mmsi}`;
|
||||
}
|
||||
|
||||
function aisDisplayNameHtml(msg) {
|
||||
const label = escapeMapHtml(aisDisplayName(msg));
|
||||
const url = window.buildAisVesselUrl ? window.buildAisVesselUrl(msg?.mmsi) : null;
|
||||
if (!url) return label;
|
||||
return `<a class="title-link" href="${escapeMapHtml(url)}" target="_blank" rel="noopener">${label}</a>`;
|
||||
}
|
||||
|
||||
function aisTypeLabel(type) {
|
||||
switch (Number(type)) {
|
||||
case 1:
|
||||
@@ -97,6 +104,16 @@ function aisRouteText(msg) {
|
||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
||||
}
|
||||
|
||||
function aisDistanceText(msg) {
|
||||
if (serverLat == null || serverLon == null || msg?.lat == null || msg?.lon == null) {
|
||||
return "";
|
||||
}
|
||||
const distKm = haversineKm(serverLat, serverLon, msg.lat, msg.lon);
|
||||
if (!Number.isFinite(distKm)) return "";
|
||||
if (distKm < 1) return `${Math.round(distKm * 1000)} m from TRX`;
|
||||
return `${distKm.toFixed(1)} km from TRX`;
|
||||
}
|
||||
|
||||
function aisLatestByVessel(messages) {
|
||||
const byMmsi = new Map();
|
||||
for (const msg of messages) {
|
||||
@@ -138,9 +155,11 @@ function renderAisRow(msg) {
|
||||
second: "2-digit",
|
||||
});
|
||||
const name = aisDisplayName(msg);
|
||||
const nameHtml = aisDisplayNameHtml(msg);
|
||||
const channel = aisChannelInfo(msg.channel);
|
||||
const motion = aisMotionText(msg);
|
||||
const route = aisRouteText(msg);
|
||||
const distance = aisDistanceText(msg);
|
||||
const pos = msg.lat != null && msg.lon != null
|
||||
? `<a class="ais-pos-link" href="javascript:void(0)" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}</a>`
|
||||
: "";
|
||||
@@ -160,7 +179,7 @@ function renderAisRow(msg) {
|
||||
row.innerHTML =
|
||||
`<div class="ais-row-head">` +
|
||||
`<span class="ais-time">${ts}</span>` +
|
||||
`<span class="ais-call">${escapeMapHtml(name)}</span>` +
|
||||
`<span class="ais-call">${nameHtml}</span>` +
|
||||
`<span class="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||
`</div>` +
|
||||
@@ -171,6 +190,7 @@ function renderAisRow(msg) {
|
||||
`</div>` +
|
||||
`<div class="ais-row-detail">` +
|
||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
||||
(pos ? `<span>${pos}</span>` : "") +
|
||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||
`</div>`;
|
||||
@@ -213,13 +233,15 @@ function updateAisBar() {
|
||||
const pin = msg.lat != null && msg.lon != null
|
||||
? `<button class="aprs-bar-pin" title="${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}" onclick="window.navigateToAprsMap(${msg.lat},${msg.lon})">📍</button>`
|
||||
: "";
|
||||
const name = `<span class="ais-call">${escapeMapHtml(aisDisplayName(msg))}</span>`;
|
||||
const name = `<span class="ais-call">${aisDisplayNameHtml(msg)}</span>`;
|
||||
const channel = aisChannelInfo(msg.channel);
|
||||
const distance = aisDistanceText(msg);
|
||||
const details = [
|
||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||
escapeMapHtml(channel.label),
|
||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||
distance ? escapeMapHtml(distance) : null,
|
||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -1116,8 +1116,28 @@ small { color: var(--text-muted); }
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
#subtab-ais {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 18rem);
|
||||
}
|
||||
#subtab-aprs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 18rem);
|
||||
}
|
||||
#aprs-packets,
|
||||
#ais-messages { max-height: 360px; overflow-y: auto; border: 1px solid var(--border-light); border-radius: 6px; background: var(--input-bg); font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
|
||||
#aprs-packets {
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(100vh - 21rem);
|
||||
max-height: none;
|
||||
}
|
||||
#ais-messages {
|
||||
flex: 1 1 auto;
|
||||
min-height: calc(100vh - 24rem);
|
||||
max-height: none;
|
||||
}
|
||||
.aprs-packet { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--border); line-height: 1.4; }
|
||||
.aprs-packet:last-child { border-bottom: none; }
|
||||
.ais-message { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.82rem; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--border); line-height: 1.35; }
|
||||
@@ -1534,6 +1554,18 @@ button:focus-visible, input:focus-visible, select:focus-visible {
|
||||
.ais-summary {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
#subtab-ais {
|
||||
min-height: calc(100vh - 14rem);
|
||||
}
|
||||
#subtab-aprs {
|
||||
min-height: calc(100vh - 14rem);
|
||||
}
|
||||
#aprs-packets {
|
||||
min-height: calc(100vh - 19rem);
|
||||
}
|
||||
#ais-messages {
|
||||
min-height: calc(100vh - 22rem);
|
||||
}
|
||||
.aprs-controls > button,
|
||||
.ft8-controls > button,
|
||||
.cw-controls > button {
|
||||
|
||||
@@ -39,6 +39,7 @@ struct FrontendMeta {
|
||||
owner_callsign: Option<String>,
|
||||
owner_website_url: Option<String>,
|
||||
owner_website_name: Option<String>,
|
||||
ais_vessel_url_base: Option<String>,
|
||||
show_sdr_gain_control: bool,
|
||||
initial_map_zoom: u8,
|
||||
spectrum_coverage_margin_hz: u32,
|
||||
@@ -93,6 +94,9 @@ fn inject_frontend_meta(json: &str, meta: FrontendMeta) -> String {
|
||||
if let Some(name) = meta.owner_website_name {
|
||||
map.insert("owner_website_name".to_string(), serde_json::json!(name));
|
||||
}
|
||||
if let Some(url) = meta.ais_vessel_url_base {
|
||||
map.insert("ais_vessel_url_base".to_string(), serde_json::json!(url));
|
||||
}
|
||||
map.insert(
|
||||
"show_sdr_gain_control".to_string(),
|
||||
serde_json::json!(meta.show_sdr_gain_control),
|
||||
@@ -126,6 +130,7 @@ fn frontend_meta_from_context(
|
||||
owner_callsign: owner_callsign_from_context(context),
|
||||
owner_website_url: owner_website_url_from_context(context),
|
||||
owner_website_name: owner_website_name_from_context(context),
|
||||
ais_vessel_url_base: ais_vessel_url_base_from_context(context),
|
||||
show_sdr_gain_control: show_sdr_gain_control_from_context(context),
|
||||
initial_map_zoom: initial_map_zoom_from_context(context),
|
||||
spectrum_coverage_margin_hz: spectrum_coverage_margin_hz_from_context(context),
|
||||
@@ -171,6 +176,10 @@ fn owner_website_name_from_context(context: &FrontendRuntimeContext) -> Option<S
|
||||
context.owner_website_name.clone()
|
||||
}
|
||||
|
||||
fn ais_vessel_url_base_from_context(context: &FrontendRuntimeContext) -> Option<String> {
|
||||
context.ais_vessel_url_base.clone()
|
||||
}
|
||||
|
||||
fn show_sdr_gain_control_from_context(context: &FrontendRuntimeContext) -> bool {
|
||||
context.http_show_sdr_gain_control
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user