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 917ba01..7e686b8 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 @@ -3476,6 +3476,73 @@ function haversineKm(lat1, lon1, lat2, lon2) { return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); } +function locatorToLatLon(locator) { + const raw = String(locator || "").trim().toUpperCase(); + if (!/^[A-R]{2}\d{2}([A-X]{2})?$/.test(raw)) return null; + let lon = -180; + let lat = -90; + lon += (raw.charCodeAt(0) - 65) * 20; + lat += (raw.charCodeAt(1) - 65) * 10; + lon += Number(raw.slice(2, 3)) * 2; + lat += Number(raw.slice(3, 4)); + if (raw.length >= 6) { + lon += (raw.charCodeAt(4) - 65) * (5 / 60); + lat += (raw.charCodeAt(5) - 65) * (2.5 / 60); + lon += 2.5 / 60; + lat += 1.25 / 60; + } else { + lon += 1; + lat += 0.5; + } + return { lat, lon }; +} + +function formatDistanceKm(distKm) { + if (!Number.isFinite(distKm)) return null; + return distKm < 1 ? `${Math.round(distKm * 1000)} m` : `${distKm.toFixed(1)} km`; +} + +function bookmarkDistanceText(bm) { + if (!bm || serverLat == null || serverLon == null) return null; + const latLon = locatorToLatLon(bm.locator); + if (!latLon) return null; + return formatDistanceKm(haversineKm(serverLat, serverLon, latLon.lat, latLon.lon)); +} + +function buildBookmarkTooltipText(bm) { + if (!bm) return null; + const parts = []; + if (bm.name) parts.push(String(bm.name)); + if (typeof bmFmtFreq === "function") parts.push(bmFmtFreq(bm.freq_hz)); + if (bm.mode) parts.push(String(bm.mode)); + if (bm.locator) parts.push(String(bm.locator)); + const distance = bookmarkDistanceText(bm); + if (distance) parts.push(distance); + let text = parts.join(" · "); + if (bm.comment) { + text += (text ? "\n" : "") + String(bm.comment); + } + return text; +} + +function nearestBookmarkForHz(hz, widthPx, range) { + const ref = typeof bmList !== "undefined" ? bmList : null; + if (!Array.isArray(ref) || !Number.isFinite(hz) || !widthPx || !range || !Number.isFinite(range.visSpanHz) || range.visSpanHz <= 0) { + return null; + } + const maxDeltaHz = Math.max((range.visSpanHz / widthPx) * 6, 10); + let best = null; + let bestDelta = Number.POSITIVE_INFINITY; + for (const bm of ref) { + const delta = Math.abs(Number(bm.freq_hz) - hz); + if (delta <= maxDeltaHz && delta < bestDelta) { + best = bm; + bestDelta = delta; + } + } + return best; +} + function formatTimeAgo(tsMs) { if (!tsMs) return null; const secs = Math.round((Date.now() - tsMs) / 1000); @@ -5571,7 +5638,7 @@ function createBookmarkChip(bm, colorMap, options = {}) { if (options.sideStack) { span.classList.add("spectrum-bookmark-chip-side"); } - span.title = bm.name + " \u2014 " + freqStr + (bm.comment ? "\n" + bm.comment : ""); + span.title = buildBookmarkTooltipText(bm) || (bm.name + " \u2014 " + freqStr + (bm.comment ? "\n" + bm.comment : "")); span.dataset.bmId = bm.id; const labelHtml = options.sideStack ? ( @@ -5931,10 +5998,13 @@ if (spectrumCanvas) { const edge = getBwEdgeHit(cssX, rect.width, range); spectrumCanvas.style.cursor = edge ? "ew-resize" : "crosshair"; const hz = canvasXToHz(cssX, rect.width, range); + const bookmark = edge ? null : nearestBookmarkForHz(hz, rect.width, range); const peak = edge ? null : nearestSpectrumPeak(cssX, rect.width, lastSpectrumData); const peakHz = peak?.hz ?? null; const peakDb = peak && Number.isFinite(peak.db) ? `${peak.db.toFixed(1)} dB` : null; - if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) { + if (bookmark) { + spectrumTooltip.textContent = buildBookmarkTooltipText(bookmark); + } else if (peakHz != null && Math.abs(peakHz - hz) >= Math.max(minFreqStepHz, 10)) { spectrumTooltip.textContent = peakDb ? `Peak ${formatSpectrumFreq(peakHz)} · ${peakDb}` : `Peak ${formatSpectrumFreq(peakHz)}`; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html index 4b4b0f3..be4b44b 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/index.html @@ -314,6 +314,9 @@ +
Decoders
@@ -338,6 +341,7 @@ Frequency Mode BW + Locator Category Decoders Comment diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js index 9006f10..97a7843 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/plugins/bookmarks.js @@ -58,6 +58,7 @@ function bmApplyFilters() { filtered = text ? filtered.filter((bm) => (bm.name || "").toLowerCase().includes(text) || + (bm.locator || "").toLowerCase().includes(text) || (bm.category || "").toLowerCase().includes(text) || (bm.comment || "").toLowerCase().includes(text) ) @@ -117,6 +118,7 @@ function bmRender(list) { const tr = document.createElement("tr"); tr.dataset.bmId = bm.id; const bwCell = bm.bandwidth_hz ? bmFmtFreq(bm.bandwidth_hz) : "--"; + const locatorCell = bm.locator || "--"; const catCell = bm.category || "Uncategorised"; const decoderCell = (bm.decoders || []).join(", ").toUpperCase() || "--"; const commentCell = bm.comment || ""; @@ -125,6 +127,7 @@ function bmRender(list) { `${bmFmtFreq(bm.freq_hz)}` + `${bmEsc(bm.mode)}` + `${bwCell}` + + `${bmEsc(locatorCell)}` + `${bmEsc(catCell)}` + `${bmEsc(decoderCell)}` + `${bmEsc(commentCell)}` + @@ -164,6 +167,7 @@ function bmOpenForm(bm) { document.getElementById("bm-freq").value = bm ? bm.freq_hz : ""; document.getElementById("bm-mode").value = bm ? bm.mode : ""; document.getElementById("bm-bw").value = bm && bm.bandwidth_hz ? bm.bandwidth_hz : ""; + document.getElementById("bm-locator").value = bm ? (bm.locator || "") : ""; document.getElementById("bm-category-input").value = bm ? (bm.category || "") : ""; document.getElementById("bm-comment").value = bm ? (bm.comment || "") : ""; bmWriteDecoders(bm ? bm.decoders : []); @@ -201,6 +205,7 @@ async function bmSave(e) { const mode = document.getElementById("bm-mode").value.trim(); const bwStr = document.getElementById("bm-bw").value; const bandwidth_hz = bwStr ? parseInt(bwStr, 10) : null; + const locator = document.getElementById("bm-locator").value.trim().toUpperCase(); const category = document.getElementById("bm-category-input").value.trim(); const comment = document.getElementById("bm-comment").value.trim(); const decoders = bmReadDecoders(); @@ -210,7 +215,16 @@ async function bmSave(e) { return; } - const body = { name, freq_hz, mode, bandwidth_hz, category, comment, decoders }; + const body = { + name, + freq_hz, + mode, + bandwidth_hz, + locator: locator || null, + category, + comment, + decoders, + }; try { let resp; 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 1868b06..1b30afe 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 @@ -744,6 +744,7 @@ pub struct BookmarkInput { pub freq_hz: u64, pub mode: String, pub bandwidth_hz: Option, + pub locator: Option, pub comment: Option, pub category: Option, pub decoders: Option>, @@ -766,6 +767,17 @@ fn gen_bookmark_id() -> String { hex::encode(rand::random::<[u8; 16]>()) } +fn normalize_bookmark_locator(locator: Option) -> Option { + locator.and_then(|value| { + let trimmed = value.trim().to_uppercase(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } + }) +} + #[get("/bookmarks")] pub async fn list_bookmarks( store: web::Data>, @@ -801,6 +813,7 @@ pub async fn create_bookmark( freq_hz: body.freq_hz, mode: body.mode.clone(), bandwidth_hz: body.bandwidth_hz, + locator: normalize_bookmark_locator(body.locator.clone()), comment: body.comment.clone().unwrap_or_default(), category: body.category.clone().unwrap_or_default(), decoders: body.decoders.clone().unwrap_or_default(), @@ -835,6 +848,7 @@ pub async fn update_bookmark( freq_hz: body.freq_hz, mode: body.mode.clone(), bandwidth_hz: body.bandwidth_hz, + locator: normalize_bookmark_locator(body.locator.clone()), comment: body.comment.clone().unwrap_or_default(), category: body.category.clone().unwrap_or_default(), decoders: body.decoders.clone().unwrap_or_default(), diff --git a/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs b/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs index 4071a98..a5de79e 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs +++ b/src/trx-client/trx-frontend/trx-frontend-http/src/bookmarks.rs @@ -15,6 +15,8 @@ pub struct Bookmark { pub freq_hz: u64, pub mode: String, pub bandwidth_hz: Option, + #[serde(default)] + pub locator: Option, pub comment: String, pub category: String, pub decoders: Vec, @@ -31,12 +33,24 @@ impl BookmarkStore { let _ = std::fs::create_dir_all(parent); } let db = if path.exists() { - PickleDb::load(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) - .unwrap_or_else(|_| { - PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) - }) + PickleDb::load( + path, + PickleDbDumpPolicy::AutoDump, + SerializationMethod::Json, + ) + .unwrap_or_else(|_| { + PickleDb::new( + path, + PickleDbDumpPolicy::AutoDump, + SerializationMethod::Json, + ) + }) } else { - PickleDb::new(path, PickleDbDumpPolicy::AutoDump, SerializationMethod::Json) + PickleDb::new( + path, + PickleDbDumpPolicy::AutoDump, + SerializationMethod::Json, + ) }; Self { db: Arc::new(RwLock::new(db)), @@ -94,8 +108,8 @@ impl BookmarkStore { /// Returns true if any bookmark (other than `exclude_id`) has `freq_hz`. pub fn freq_taken(&self, freq_hz: u64, exclude_id: Option<&str>) -> bool { - self.list().into_iter().any(|bm| { - bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex) - }) + self.list() + .into_iter() + .any(|bm| bm.freq_hz == freq_hz && exclude_id.is_none_or(|ex| bm.id != ex)) } }