[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:
2026-03-02 23:28:41 +01:00
parent 27f45feedd
commit 3099ae7d68
8 changed files with 312 additions and 32 deletions
+133 -17
View File
@@ -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);
}
} }
+15
View File
@@ -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()));
+1
View File
@@ -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: {})",
+3
View File
@@ -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)}&deg;</td></tr>`; if (msg?.cog_deg != null) rows += `<tr><td class="aprs-popup-label">COG</td><td>${Number(msg.cog_deg).toFixed(1)}&deg;</td></tr>`;
if (msg?.heading_deg != null) rows += `<tr><td class="aprs-popup-label">HDG</td><td>${Number(msg.heading_deg).toFixed(0)}&deg;</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
} }