[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:
+133
-17
@@ -50,10 +50,15 @@ struct RawFrame {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AisDecoder {
|
pub struct AisDecoder {
|
||||||
sample_rate: f32,
|
sample_rate: f32,
|
||||||
symbol_phase: f32,
|
samples_per_symbol: f32,
|
||||||
|
sample_clock: f32,
|
||||||
dc_state: f32,
|
dc_state: f32,
|
||||||
lp_state: f32,
|
lp_fast: f32,
|
||||||
|
lp_slow: f32,
|
||||||
env_state: f32,
|
env_state: f32,
|
||||||
|
polarity: i8,
|
||||||
|
samples_since_transition: u32,
|
||||||
|
clock_locked: bool,
|
||||||
prev_raw_bit: u8,
|
prev_raw_bit: u8,
|
||||||
ones: u32,
|
ones: u32,
|
||||||
in_frame: bool,
|
in_frame: bool,
|
||||||
@@ -63,12 +68,18 @@ pub struct AisDecoder {
|
|||||||
|
|
||||||
impl AisDecoder {
|
impl AisDecoder {
|
||||||
pub fn new(sample_rate: u32) -> Self {
|
pub fn new(sample_rate: u32) -> Self {
|
||||||
|
let sample_rate = sample_rate.max(1) as f32;
|
||||||
Self {
|
Self {
|
||||||
sample_rate: sample_rate.max(1) as f32,
|
sample_rate,
|
||||||
symbol_phase: 0.0,
|
samples_per_symbol: sample_rate / AIS_BAUD,
|
||||||
|
sample_clock: 0.0,
|
||||||
dc_state: 0.0,
|
dc_state: 0.0,
|
||||||
lp_state: 0.0,
|
lp_fast: 0.0,
|
||||||
|
lp_slow: 0.0,
|
||||||
env_state: 1e-3,
|
env_state: 1e-3,
|
||||||
|
polarity: 1,
|
||||||
|
samples_since_transition: 0,
|
||||||
|
clock_locked: false,
|
||||||
prev_raw_bit: 0,
|
prev_raw_bit: 0,
|
||||||
ones: 0,
|
ones: 0,
|
||||||
in_frame: false,
|
in_frame: false,
|
||||||
@@ -78,10 +89,15 @@ impl AisDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn reset(&mut self) {
|
pub fn reset(&mut self) {
|
||||||
self.symbol_phase = 0.0;
|
self.samples_per_symbol = self.sample_rate / AIS_BAUD;
|
||||||
|
self.sample_clock = 0.0;
|
||||||
self.dc_state = 0.0;
|
self.dc_state = 0.0;
|
||||||
self.lp_state = 0.0;
|
self.lp_fast = 0.0;
|
||||||
|
self.lp_slow = 0.0;
|
||||||
self.env_state = 1e-3;
|
self.env_state = 1e-3;
|
||||||
|
self.polarity = 1;
|
||||||
|
self.samples_since_transition = 0;
|
||||||
|
self.clock_locked = false;
|
||||||
self.prev_raw_bit = 0;
|
self.prev_raw_bit = 0;
|
||||||
self.ones = 0;
|
self.ones = 0;
|
||||||
self.in_frame = false;
|
self.in_frame = false;
|
||||||
@@ -109,25 +125,59 @@ impl AisDecoder {
|
|||||||
self.dc_state += 0.0025 * (sample - self.dc_state);
|
self.dc_state += 0.0025 * (sample - self.dc_state);
|
||||||
let dc_free = sample - self.dc_state;
|
let dc_free = sample - self.dc_state;
|
||||||
|
|
||||||
// Gentle low-pass smoothing to suppress narrow impulsive noise.
|
// A simple band-pass-ish response makes GMSK symbol transitions stand out
|
||||||
self.lp_state += 0.28 * (dc_free - self.lp_state);
|
// without needing a full matched filter.
|
||||||
|
self.lp_fast += 0.32 * (dc_free - self.lp_fast);
|
||||||
|
self.lp_slow += 0.045 * (dc_free - self.lp_slow);
|
||||||
|
let shaped = self.lp_fast - self.lp_slow;
|
||||||
|
|
||||||
// Track envelope to keep the slicer stable on weak signals.
|
// Track envelope to keep the slicer stable on weak signals.
|
||||||
self.env_state += 0.02 * (self.lp_state.abs() - self.env_state);
|
self.env_state += 0.015 * (shaped.abs() - self.env_state);
|
||||||
let normalized = if self.env_state > 1e-4 {
|
let normalized = if self.env_state > 1e-4 {
|
||||||
self.lp_state / self.env_state
|
shaped / self.env_state
|
||||||
} else {
|
} else {
|
||||||
self.lp_state
|
shaped
|
||||||
};
|
};
|
||||||
|
|
||||||
self.symbol_phase += AIS_BAUD;
|
let threshold = 0.12;
|
||||||
while self.symbol_phase >= self.sample_rate {
|
let next_polarity = if normalized > threshold {
|
||||||
self.symbol_phase -= self.sample_rate;
|
1
|
||||||
let raw_bit = if normalized >= 0.0 { 1 } else { 0 };
|
} else if normalized < -threshold {
|
||||||
|
-1
|
||||||
|
} else {
|
||||||
|
self.polarity
|
||||||
|
};
|
||||||
|
|
||||||
|
self.samples_since_transition = self.samples_since_transition.saturating_add(1);
|
||||||
|
if next_polarity != self.polarity {
|
||||||
|
self.observe_transition();
|
||||||
|
self.polarity = next_polarity;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !self.clock_locked {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.sample_clock += 1.0;
|
||||||
|
while self.sample_clock >= self.samples_per_symbol {
|
||||||
|
self.sample_clock -= self.samples_per_symbol;
|
||||||
|
let raw_bit = if self.polarity >= 0 { 1 } else { 0 };
|
||||||
self.process_symbol(raw_bit);
|
self.process_symbol(raw_bit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn observe_transition(&mut self) {
|
||||||
|
let interval = self.samples_since_transition.max(1) as f32;
|
||||||
|
self.samples_since_transition = 0;
|
||||||
|
|
||||||
|
let nominal = (self.sample_rate / AIS_BAUD).max(1.0);
|
||||||
|
let symbols = (interval / nominal).round().clamp(1.0, 8.0);
|
||||||
|
let estimate = (interval / symbols).clamp(nominal * 0.75, nominal * 1.25);
|
||||||
|
self.samples_per_symbol += 0.18 * (estimate - self.samples_per_symbol);
|
||||||
|
self.sample_clock = self.samples_per_symbol * 0.5;
|
||||||
|
self.clock_locked = true;
|
||||||
|
}
|
||||||
|
|
||||||
fn process_symbol(&mut self, raw_bit: u8) {
|
fn process_symbol(&mut self, raw_bit: u8) {
|
||||||
let decoded_bit = if raw_bit == self.prev_raw_bit { 1 } else { 0 };
|
let decoded_bit = if raw_bit == self.prev_raw_bit { 1 } else { 0 };
|
||||||
self.prev_raw_bit = raw_bit;
|
self.prev_raw_bit = raw_bit;
|
||||||
@@ -146,7 +196,7 @@ impl AisDecoder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.ones == 6 {
|
if self.ones == 6 {
|
||||||
if self.in_frame && self.frame_bits.len() >= 256 {
|
if self.in_frame {
|
||||||
if let Some(frame) = self.bits_to_frame() {
|
if let Some(frame) = self.bits_to_frame() {
|
||||||
self.frames.push(frame);
|
self.frames.push(frame);
|
||||||
}
|
}
|
||||||
@@ -373,6 +423,52 @@ fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn payload_with_crc(payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = payload.to_vec();
|
||||||
|
out.extend_from_slice(&crc16ccitt(payload).to_le_bytes());
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_lsb_bits(bytes: &[u8]) -> Vec<u8> {
|
||||||
|
let mut bits = Vec::with_capacity(bytes.len() * 8);
|
||||||
|
for &byte in bytes {
|
||||||
|
for shift in 0..8 {
|
||||||
|
bits.push((byte >> shift) & 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bits
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bitstuff(bits: &[u8]) -> Vec<u8> {
|
||||||
|
let mut out = Vec::with_capacity(bits.len() + bits.len() / 5);
|
||||||
|
let mut ones = 0u32;
|
||||||
|
for &bit in bits {
|
||||||
|
out.push(bit);
|
||||||
|
if bit == 1 {
|
||||||
|
ones += 1;
|
||||||
|
if ones == 5 {
|
||||||
|
out.push(0);
|
||||||
|
ones = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ones = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nrzi_encode(bits: &[u8]) -> Vec<u8> {
|
||||||
|
let mut state = 0u8;
|
||||||
|
let mut out = Vec::with_capacity(bits.len());
|
||||||
|
for &bit in bits {
|
||||||
|
if bit == 0 {
|
||||||
|
state ^= 1;
|
||||||
|
}
|
||||||
|
out.push(state);
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn decodes_signed_coordinates() {
|
fn decodes_signed_coordinates() {
|
||||||
assert_eq!(decode_coord(60_000, 181.0), Some(0.1));
|
assert_eq!(decode_coord(60_000, 181.0), Some(0.1));
|
||||||
@@ -386,4 +482,24 @@ mod tests {
|
|||||||
let text = decode_sixbit_text(&bits, 0, 36);
|
let text = decode_sixbit_text(&bits, 0, 36);
|
||||||
assert!(text.is_some());
|
assert!(text.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovers_hdlc_frame_from_raw_nrzi_bits() {
|
||||||
|
let payload = [0x11_u8, 0x22_u8, 0x7E_u8, 0x00_u8, 0xF0_u8];
|
||||||
|
let frame_bytes = payload_with_crc(&payload);
|
||||||
|
let mut hdlc_bits = bytes_to_lsb_bits(&[0x7E]);
|
||||||
|
hdlc_bits.extend(bitstuff(&bytes_to_lsb_bits(&frame_bytes)));
|
||||||
|
hdlc_bits.extend(bytes_to_lsb_bits(&[0x7E]));
|
||||||
|
let raw_bits = nrzi_encode(&hdlc_bits);
|
||||||
|
|
||||||
|
let mut decoder = AisDecoder::new(48_000);
|
||||||
|
for raw_bit in raw_bits {
|
||||||
|
decoder.process_symbol(raw_bit);
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(decoder.frames.len(), 1);
|
||||||
|
let frame = &decoder.frames[0];
|
||||||
|
assert!(frame.crc_ok);
|
||||||
|
assert_eq!(frame.payload, payload);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ pub struct GeneralConfig {
|
|||||||
pub website_url: Option<String>,
|
pub website_url: Option<String>,
|
||||||
/// Optional website name to use as the web UI header title label.
|
/// Optional website name to use as the web UI header title label.
|
||||||
pub website_name: Option<String>,
|
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)
|
/// Log level (trace, debug, info, warn, error)
|
||||||
pub log_level: Option<String>,
|
pub log_level: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -51,6 +53,7 @@ impl Default for GeneralConfig {
|
|||||||
callsign: Some("N0CALL".to_string()),
|
callsign: Some("N0CALL".to_string()),
|
||||||
website_url: None,
|
website_url: None,
|
||||||
website_name: None,
|
website_name: None,
|
||||||
|
ais_vessel_url_base: Some("https://www.vesselfinder.com/?mmsi=".to_string()),
|
||||||
log_level: None,
|
log_level: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +359,11 @@ impl ClientConfig {
|
|||||||
return Err("[general].website_name must not be empty when set".to_string());
|
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 {
|
if self.frontends.http.enabled && self.frontends.http.port == 0 {
|
||||||
return Err("[frontends.http].port must be > 0 when enabled".to_string());
|
return Err("[frontends.http].port must be > 0 when enabled".to_string());
|
||||||
@@ -459,6 +467,7 @@ impl ClientConfig {
|
|||||||
callsign: Some("N0CALL".to_string()),
|
callsign: Some("N0CALL".to_string()),
|
||||||
website_url: Some("https://haxx.space".to_string()),
|
website_url: Some("https://haxx.space".to_string()),
|
||||||
website_name: Some("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()),
|
log_level: Some("info".to_string()),
|
||||||
},
|
},
|
||||||
remote: RemoteConfig {
|
remote: RemoteConfig {
|
||||||
@@ -586,6 +595,7 @@ mod tests {
|
|||||||
assert!(config.remote.url.is_none());
|
assert!(config.remote.url.is_none());
|
||||||
assert!(config.general.website_url.is_none());
|
assert!(config.general.website_url.is_none());
|
||||||
assert!(config.general.website_name.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_eq!(config.remote.poll_interval_ms, 750);
|
||||||
assert!(config.frontends.audio.enabled);
|
assert!(config.frontends.audio.enabled);
|
||||||
assert_eq!(config.frontends.audio.server_port, 4531);
|
assert_eq!(config.frontends.audio.server_port, 4531);
|
||||||
@@ -602,6 +612,7 @@ mod tests {
|
|||||||
callsign = "W1AW"
|
callsign = "W1AW"
|
||||||
website_url = "https://example.com"
|
website_url = "https://example.com"
|
||||||
website_name = "Example"
|
website_name = "Example"
|
||||||
|
ais_vessel_url_base = "https://example.com/vessel/"
|
||||||
|
|
||||||
[remote]
|
[remote]
|
||||||
url = "192.168.1.100:9000"
|
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.callsign, Some("W1AW".to_string()));
|
||||||
assert_eq!(config.general.website_url, Some("https://example.com".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.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.url, Some("192.168.1.100:9000".to_string()));
|
||||||
assert_eq!(config.remote.rig_id, Some("hf".to_string()));
|
assert_eq!(config.remote.rig_id, Some("hf".to_string()));
|
||||||
assert_eq!(config.remote.auth.token, Some("my-token".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_callsign = callsign.clone();
|
||||||
frontend_runtime.owner_website_url = cfg.general.website_url.clone();
|
frontend_runtime.owner_website_url = cfg.general.website_url.clone();
|
||||||
frontend_runtime.owner_website_name = cfg.general.website_name.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!(
|
info!(
|
||||||
"Starting trx-client (remote: {}, frontends: {})",
|
"Starting trx-client (remote: {}, frontends: {})",
|
||||||
|
|||||||
@@ -186,6 +186,8 @@ pub struct FrontendRuntimeContext {
|
|||||||
pub owner_website_url: Option<String>,
|
pub owner_website_url: Option<String>,
|
||||||
/// Optional website name for the web UI header title label.
|
/// Optional website name for the web UI header title label.
|
||||||
pub owner_website_name: Option<String>,
|
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.
|
/// Latest spectrum frame from the active SDR rig; None for non-SDR backends.
|
||||||
pub spectrum: Arc<Mutex<SharedSpectrum>>,
|
pub spectrum: Arc<Mutex<SharedSpectrum>>,
|
||||||
}
|
}
|
||||||
@@ -223,6 +225,7 @@ impl FrontendRuntimeContext {
|
|||||||
owner_callsign: None,
|
owner_callsign: None,
|
||||||
owner_website_url: None,
|
owner_website_url: None,
|
||||||
owner_website_name: None,
|
owner_website_name: None,
|
||||||
|
ais_vessel_url_base: None,
|
||||||
spectrum: Arc::new(Mutex::new(SharedSpectrum::default())),
|
spectrum: Arc::new(Mutex::new(SharedSpectrum::default())),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1723,6 +1723,7 @@ let serverCallsign = null;
|
|||||||
let ownerCallsign = null;
|
let ownerCallsign = null;
|
||||||
let ownerWebsiteUrl = null;
|
let ownerWebsiteUrl = null;
|
||||||
let ownerWebsiteName = null;
|
let ownerWebsiteName = null;
|
||||||
|
let aisVesselUrlBase = null;
|
||||||
let serverRigs = [];
|
let serverRigs = [];
|
||||||
let serverActiveRigId = null;
|
let serverActiveRigId = null;
|
||||||
let serverLat = 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) {
|
function render(update) {
|
||||||
if (!update) return;
|
if (!update) return;
|
||||||
if (update.server_version) serverVersion = update.server_version;
|
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) {
|
if (typeof update.owner_website_name === "string" && update.owner_website_name.length > 0) {
|
||||||
ownerWebsiteName = update.owner_website_name;
|
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_latitude != null) serverLat = update.server_latitude;
|
||||||
if (update.server_longitude != null) serverLon = update.server_longitude;
|
if (update.server_longitude != null) serverLon = update.server_longitude;
|
||||||
if (typeof update.initial_map_zoom === "number" && Number.isFinite(update.initial_map_zoom)) {
|
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 cwStatus = document.getElementById("cw-status");
|
||||||
const ft8Status = document.getElementById("ft8-status");
|
const ft8Status = document.getElementById("ft8-status");
|
||||||
const wsprStatus = document.getElementById("wspr-status");
|
const wsprStatus = document.getElementById("wspr-status");
|
||||||
if (aisStatus && modeUpper !== "AIS" && aisStatus.textContent === "Receiving") {
|
setModeBoundDecodeStatus(
|
||||||
aisStatus.textContent = "Connected, listening for packets";
|
aisStatus,
|
||||||
}
|
["AIS"],
|
||||||
|
"Select AIS mode to decode",
|
||||||
|
"Connected, listening for packets",
|
||||||
|
);
|
||||||
if (window.updateAisBar) window.updateAisBar();
|
if (window.updateAisBar) window.updateAisBar();
|
||||||
if (aprsStatus && modeUpper !== "PKT" && aprsStatus.textContent === "Receiving") {
|
setModeBoundDecodeStatus(
|
||||||
aprsStatus.textContent = "Connected, listening for packets";
|
aprsStatus,
|
||||||
}
|
["PKT"],
|
||||||
|
"Select PKT mode to decode",
|
||||||
|
"Connected, listening for packets",
|
||||||
|
);
|
||||||
if (window.updateAprsBar) window.updateAprsBar();
|
if (window.updateAprsBar) window.updateAprsBar();
|
||||||
if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") {
|
if (cwStatus && modeUpper !== "CW" && modeUpper !== "CWR" && cwStatus.textContent === "Receiving") {
|
||||||
cwStatus.textContent = "Connected, listening for packets";
|
cwStatus.textContent = "Connected, listening for packets";
|
||||||
@@ -2969,6 +2984,7 @@ const locatorMarkers = new Map();
|
|||||||
const mapMarkers = new Set();
|
const mapMarkers = new Set();
|
||||||
const mapFilter = { ais: true, aprs: true, ft8: true, wspr: true };
|
const mapFilter = { ais: true, aprs: true, ft8: true, wspr: true };
|
||||||
const APRS_TRACK_MAX_POINTS = 64;
|
const APRS_TRACK_MAX_POINTS = 64;
|
||||||
|
const AIS_TRACK_MAX_POINTS = 64;
|
||||||
const aisMarkers = new Map();
|
const aisMarkers = new Map();
|
||||||
|
|
||||||
window.clearMapMarkersByType = function(type) {
|
window.clearMapMarkersByType = function(type) {
|
||||||
@@ -2993,6 +3009,10 @@ window.clearMapMarkersByType = function(type) {
|
|||||||
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
if (aprsMap && aprsMap.hasLayer(entry.marker)) entry.marker.removeFrom(aprsMap);
|
||||||
mapMarkers.delete(entry.marker);
|
mapMarkers.delete(entry.marker);
|
||||||
}
|
}
|
||||||
|
if (entry && entry.track) {
|
||||||
|
if (aprsMap && aprsMap.hasLayer(entry.track)) entry.track.removeFrom(aprsMap);
|
||||||
|
mapMarkers.delete(entry.track);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
aisMarkers.clear();
|
aisMarkers.clear();
|
||||||
return;
|
return;
|
||||||
@@ -3261,12 +3281,20 @@ function buildAisPopupHtml(msg) {
|
|||||||
let rows = "";
|
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">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>`;
|
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?.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?.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>`;
|
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 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">` +
|
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>` : "") +
|
(meta ? `<div class="aprs-popup-meta">${meta}</div>` : "") +
|
||||||
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
|
(rows ? `<table class="aprs-popup-table">${rows}</table>` : "") +
|
||||||
(info ? `<div class="aprs-popup-info">${info}</div>` : "") +
|
(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;
|
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) {
|
function ensureAprsTrack(call, entry) {
|
||||||
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
|
if (!aprsMap || !entry || !Array.isArray(entry.trackPoints) || entry.trackPoints.length < 2) return;
|
||||||
if (entry.track) {
|
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) {
|
window.aisMapAddVessel = function(msg) {
|
||||||
if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
|
if (msg == null || msg.lat == null || msg.lon == null || !Number.isFinite(msg.mmsi)) return;
|
||||||
if (!aprsMap) initAprsMap();
|
if (!aprsMap) initAprsMap();
|
||||||
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 existing = aisMarkers.get(key);
|
const existing = aisMarkers.get(key);
|
||||||
if (existing && existing.marker) {
|
if (existing) {
|
||||||
existing.msg = msg;
|
existing.msg = msg;
|
||||||
existing.marker.setLatLng([msg.lat, msg.lon]);
|
if (!Array.isArray(existing.trackPoints)) existing.trackPoints = [];
|
||||||
existing.marker.setPopupContent(popupHtml);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (!aprsMap) return;
|
if (!aprsMap) return;
|
||||||
@@ -3381,8 +3450,14 @@ window.aisMapAddVessel = function(msg) {
|
|||||||
fillOpacity: 0.82,
|
fillOpacity: 0.82,
|
||||||
}).addTo(aprsMap).bindPopup(popupHtml);
|
}).addTo(aprsMap).bindPopup(popupHtml);
|
||||||
marker.__trxType = "ais";
|
marker.__trxType = "ais";
|
||||||
|
marker._aisMmsi = key;
|
||||||
mapMarkers.add(marker);
|
mapMarkers.add(marker);
|
||||||
aisMarkers.set(key, { marker, msg });
|
aisMarkers.set(key, {
|
||||||
|
marker,
|
||||||
|
track: null,
|
||||||
|
trackPoints: [nextPoint],
|
||||||
|
msg,
|
||||||
|
});
|
||||||
applyMapFilter();
|
applyMapFilter();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4109,13 +4184,20 @@ document.getElementById("copyright-year").textContent = new Date().getFullYear()
|
|||||||
// --- Server-side decode SSE ---
|
// --- Server-side decode SSE ---
|
||||||
let decodeSource = null;
|
let decodeSource = null;
|
||||||
let decodeConnected = false;
|
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) {
|
function updateDecodeStatus(text) {
|
||||||
const ais = document.getElementById("ais-status");
|
const ais = document.getElementById("ais-status");
|
||||||
const aprs = document.getElementById("aprs-status");
|
const aprs = document.getElementById("aprs-status");
|
||||||
const cw = document.getElementById("cw-status");
|
const cw = document.getElementById("cw-status");
|
||||||
const ft8 = document.getElementById("ft8-status");
|
const ft8 = document.getElementById("ft8-status");
|
||||||
if (ais && ais.textContent !== "Receiving") ais.textContent = text;
|
setModeBoundDecodeStatus(ais, ["AIS"], "Select AIS mode to decode", text);
|
||||||
if (aprs && aprs.textContent !== "Receiving") aprs.textContent = text;
|
setModeBoundDecodeStatus(aprs, ["PKT"], "Select PKT mode to decode", text);
|
||||||
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
if (cw && cw.textContent !== "Receiving") cw.textContent = text;
|
||||||
if (ft8 && ft8.textContent !== "Receiving") ft8.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}`;
|
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) {
|
function aisTypeLabel(type) {
|
||||||
switch (Number(type)) {
|
switch (Number(type)) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -97,6 +104,16 @@ function aisRouteText(msg) {
|
|||||||
return [msg.callsign, msg.destination].filter(Boolean).join(" -> ");
|
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) {
|
function aisLatestByVessel(messages) {
|
||||||
const byMmsi = new Map();
|
const byMmsi = new Map();
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
@@ -138,9 +155,11 @@ function renderAisRow(msg) {
|
|||||||
second: "2-digit",
|
second: "2-digit",
|
||||||
});
|
});
|
||||||
const name = aisDisplayName(msg);
|
const name = aisDisplayName(msg);
|
||||||
|
const nameHtml = aisDisplayNameHtml(msg);
|
||||||
const channel = aisChannelInfo(msg.channel);
|
const channel = aisChannelInfo(msg.channel);
|
||||||
const motion = aisMotionText(msg);
|
const motion = aisMotionText(msg);
|
||||||
const route = aisRouteText(msg);
|
const route = aisRouteText(msg);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
const pos = msg.lat != null && msg.lon != null
|
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>`
|
? `<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 =
|
row.innerHTML =
|
||||||
`<div class="ais-row-head">` +
|
`<div class="ais-row-head">` +
|
||||||
`<span class="ais-time">${ts}</span>` +
|
`<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="${channel.badgeClass}">${escapeMapHtml(channel.label)}</span>` +
|
||||||
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
`<span class="ais-badge ais-badge-type">${escapeMapHtml(aisTypeLabel(msg.message_type))}</span>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
@@ -171,6 +190,7 @@ function renderAisRow(msg) {
|
|||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="ais-row-detail">` +
|
`<div class="ais-row-detail">` +
|
||||||
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
(motion ? `<span>${escapeMapHtml(motion)}</span>` : `<span>No motion data</span>`) +
|
||||||
|
(distance ? `<span>${escapeMapHtml(distance)}</span>` : "") +
|
||||||
(pos ? `<span>${pos}</span>` : "") +
|
(pos ? `<span>${pos}</span>` : "") +
|
||||||
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
`<span>${escapeMapHtml(aisAgeText(msg._tsMs))}</span>` +
|
||||||
`</div>`;
|
`</div>`;
|
||||||
@@ -213,13 +233,15 @@ function updateAisBar() {
|
|||||||
const pin = msg.lat != null && msg.lon != null
|
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>`
|
? `<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 channel = aisChannelInfo(msg.channel);
|
||||||
|
const distance = aisDistanceText(msg);
|
||||||
const details = [
|
const details = [
|
||||||
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
`MMSI ${escapeMapHtml(String(msg.mmsi))}`,
|
||||||
escapeMapHtml(channel.label),
|
escapeMapHtml(channel.label),
|
||||||
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
msg.sog_knots != null ? `${Number(msg.sog_knots).toFixed(1)} kn` : null,
|
||||||
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
msg.cog_deg != null ? `${Number(msg.cog_deg).toFixed(1)}°` : null,
|
||||||
|
distance ? escapeMapHtml(distance) : null,
|
||||||
escapeMapHtml(aisAgeText(msg._tsMs)),
|
escapeMapHtml(aisAgeText(msg._tsMs)),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
|
|||||||
@@ -1116,8 +1116,28 @@ small { color: var(--text-muted); }
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
line-height: 1.3;
|
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,
|
#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; }
|
#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 { 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; }
|
.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; }
|
.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 {
|
.ais-summary {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
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,
|
.aprs-controls > button,
|
||||||
.ft8-controls > button,
|
.ft8-controls > button,
|
||||||
.cw-controls > button {
|
.cw-controls > button {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ struct FrontendMeta {
|
|||||||
owner_callsign: Option<String>,
|
owner_callsign: Option<String>,
|
||||||
owner_website_url: Option<String>,
|
owner_website_url: Option<String>,
|
||||||
owner_website_name: Option<String>,
|
owner_website_name: Option<String>,
|
||||||
|
ais_vessel_url_base: Option<String>,
|
||||||
show_sdr_gain_control: bool,
|
show_sdr_gain_control: bool,
|
||||||
initial_map_zoom: u8,
|
initial_map_zoom: u8,
|
||||||
spectrum_coverage_margin_hz: u32,
|
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 {
|
if let Some(name) = meta.owner_website_name {
|
||||||
map.insert("owner_website_name".to_string(), serde_json::json!(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(
|
map.insert(
|
||||||
"show_sdr_gain_control".to_string(),
|
"show_sdr_gain_control".to_string(),
|
||||||
serde_json::json!(meta.show_sdr_gain_control),
|
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_callsign: owner_callsign_from_context(context),
|
||||||
owner_website_url: owner_website_url_from_context(context),
|
owner_website_url: owner_website_url_from_context(context),
|
||||||
owner_website_name: owner_website_name_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),
|
show_sdr_gain_control: show_sdr_gain_control_from_context(context),
|
||||||
initial_map_zoom: initial_map_zoom_from_context(context),
|
initial_map_zoom: initial_map_zoom_from_context(context),
|
||||||
spectrum_coverage_margin_hz: spectrum_coverage_margin_hz_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()
|
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 {
|
fn show_sdr_gain_control_from_context(context: &FrontendRuntimeContext) -> bool {
|
||||||
context.http_show_sdr_gain_control
|
context.http_show_sdr_gain_control
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user