diff --git a/src/decoders/trx-ais/src/lib.rs b/src/decoders/trx-ais/src/lib.rs index a10baf5..b9ba7a5 100644 --- a/src/decoders/trx-ais/src/lib.rs +++ b/src/decoders/trx-ais/src/lib.rs @@ -50,10 +50,15 @@ struct RawFrame { #[derive(Debug, Clone)] pub struct AisDecoder { sample_rate: f32, - symbol_phase: f32, + samples_per_symbol: f32, + sample_clock: f32, dc_state: f32, - lp_state: f32, + lp_fast: f32, + lp_slow: f32, env_state: f32, + polarity: i8, + samples_since_transition: u32, + clock_locked: bool, prev_raw_bit: u8, ones: u32, in_frame: bool, @@ -63,12 +68,18 @@ pub struct AisDecoder { impl AisDecoder { pub fn new(sample_rate: u32) -> Self { + let sample_rate = sample_rate.max(1) as f32; Self { - sample_rate: sample_rate.max(1) as f32, - symbol_phase: 0.0, + sample_rate, + samples_per_symbol: sample_rate / AIS_BAUD, + sample_clock: 0.0, dc_state: 0.0, - lp_state: 0.0, + lp_fast: 0.0, + lp_slow: 0.0, env_state: 1e-3, + polarity: 1, + samples_since_transition: 0, + clock_locked: false, prev_raw_bit: 0, ones: 0, in_frame: false, @@ -78,10 +89,15 @@ impl AisDecoder { } 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.lp_state = 0.0; + self.lp_fast = 0.0; + self.lp_slow = 0.0; self.env_state = 1e-3; + self.polarity = 1; + self.samples_since_transition = 0; + self.clock_locked = false; self.prev_raw_bit = 0; self.ones = 0; self.in_frame = false; @@ -109,25 +125,59 @@ impl AisDecoder { self.dc_state += 0.0025 * (sample - self.dc_state); let dc_free = sample - self.dc_state; - // Gentle low-pass smoothing to suppress narrow impulsive noise. - self.lp_state += 0.28 * (dc_free - self.lp_state); + // A simple band-pass-ish response makes GMSK symbol transitions stand out + // 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. - 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 { - self.lp_state / self.env_state + shaped / self.env_state } else { - self.lp_state + shaped }; - self.symbol_phase += AIS_BAUD; - while self.symbol_phase >= self.sample_rate { - self.symbol_phase -= self.sample_rate; - let raw_bit = if normalized >= 0.0 { 1 } else { 0 }; + let threshold = 0.12; + let next_polarity = if normalized > threshold { + 1 + } 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); } } + 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) { let decoded_bit = if raw_bit == self.prev_raw_bit { 1 } else { 0 }; self.prev_raw_bit = raw_bit; @@ -146,7 +196,7 @@ impl AisDecoder { } 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() { self.frames.push(frame); } @@ -373,6 +423,52 @@ fn decode_sixbit_text(bits: &[u8], start: usize, len: usize) -> Option { mod tests { use super::*; + fn payload_with_crc(payload: &[u8]) -> Vec { + let mut out = payload.to_vec(); + out.extend_from_slice(&crc16ccitt(payload).to_le_bytes()); + out + } + + fn bytes_to_lsb_bits(bytes: &[u8]) -> Vec { + 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 { + 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 { + 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] fn decodes_signed_coordinates() { 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); 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); + } } diff --git a/src/trx-client/src/config.rs b/src/trx-client/src/config.rs index 60228d8..6c486a9 100644 --- a/src/trx-client/src/config.rs +++ b/src/trx-client/src/config.rs @@ -41,6 +41,8 @@ pub struct GeneralConfig { pub website_url: Option, /// Optional website name to use as the web UI header title label. pub website_name: Option, + /// Optional base URL used to link AIS vessel names as ``. + pub ais_vessel_url_base: Option, /// Log level (trace, debug, info, warn, error) pub log_level: Option, } @@ -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())); diff --git a/src/trx-client/src/main.rs b/src/trx-client/src/main.rs index b734ca9..6860db0 100644 --- a/src/trx-client/src/main.rs +++ b/src/trx-client/src/main.rs @@ -250,6 +250,7 @@ async fn async_init() -> DynResult { 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: {})", diff --git a/src/trx-client/trx-frontend/src/lib.rs b/src/trx-client/trx-frontend/src/lib.rs index f91b6f6..2b516b5 100644 --- a/src/trx-client/trx-frontend/src/lib.rs +++ b/src/trx-client/trx-frontend/src/lib.rs @@ -186,6 +186,8 @@ pub struct FrontendRuntimeContext { pub owner_website_url: Option, /// Optional website name for the web UI header title label. pub owner_website_name: Option, + /// Optional base URL used to link AIS vessel names as ``. + pub ais_vessel_url_base: Option, /// Latest spectrum frame from the active SDR rig; None for non-SDR backends. pub spectrum: Arc>, } @@ -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())), } } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js index f47c081..9724c0d 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/app.js @@ -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 += `MMSI${escapeMapHtml(String(msg.mmsi || "--"))}`; rows += `Type${escapeMapHtml(String(msg.message_type || "--"))}`; + if (distStr) rows += `Range${distStr} from TRX`; if (msg?.sog_knots != null) rows += `SOG${Number(msg.sog_knots).toFixed(1)} kn`; if (msg?.cog_deg != null) rows += `COG${Number(msg.cog_deg).toFixed(1)}°`; + if (msg?.heading_deg != null) rows += `HDG${Number(msg.heading_deg).toFixed(0)}°`; + if (msg?.nav_status != null) rows += `Nav${escapeMapHtml(String(msg.nav_status))}`; if (msg?.lat != null && msg?.lon != null) rows += `Pos${msg.lat.toFixed(5)}, ${msg.lon.toFixed(5)}`; 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 + ? `${vesselLabel}` + : vesselLabel; return `
` + - `
${escapeMapHtml(msg?.vessel_name || `MMSI ${msg?.mmsi || "--"}`)}
` + + `
${vesselTitle}
` + (meta ? `
${meta}
` : "") + (rows ? `${rows}
` : "") + (info ? `
${info}
` : "") + @@ -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; } diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js index c764b0d..142c199 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/ais.js @@ -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 `${label}`; +} + 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 ? `${msg.lat.toFixed(4)}, ${msg.lon.toFixed(4)}` : ""; @@ -160,7 +179,7 @@ function renderAisRow(msg) { row.innerHTML = `
` + `${ts}` + - `${escapeMapHtml(name)}` + + `${nameHtml}` + `${escapeMapHtml(channel.label)}` + `${escapeMapHtml(aisTypeLabel(msg.message_type))}` + `
` + @@ -171,6 +190,7 @@ function renderAisRow(msg) { `
` + `
` + (motion ? `${escapeMapHtml(motion)}` : `No motion data`) + + (distance ? `${escapeMapHtml(distance)}` : "") + (pos ? `${pos}` : "") + `${escapeMapHtml(aisAgeText(msg._tsMs))}` + `
`; @@ -213,13 +233,15 @@ function updateAisBar() { const pin = msg.lat != null && msg.lon != null ? `` : ""; - const name = `${escapeMapHtml(aisDisplayName(msg))}`; + const name = `${aisDisplayNameHtml(msg)}`; 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) diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css index 9c8b45c..604e133 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/style.css @@ -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 { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs index c5ea84a..884fd7c 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/api.rs @@ -39,6 +39,7 @@ struct FrontendMeta { owner_callsign: Option, owner_website_url: Option, owner_website_name: Option, + ais_vessel_url_base: Option, 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 Option { + context.ais_vessel_url_base.clone() +} + fn show_sdr_gain_control_from_context(context: &FrontendRuntimeContext) -> bool { context.http_show_sdr_gain_control }