From 587b06c6d83bc453b25f67d41e51bdd46274c6d5 Mon Sep 17 00:00:00 2001 From: Stan Grams Date: Sun, 1 Mar 2026 19:39:09 +0100 Subject: [PATCH] [feat](trx-frontend-http): overlay bookmarks on spectrum; enforce one per freq Draw bookmark frequency markers on the spectrum canvas: amber vertical line + ribbon shape (rectangle with V-notch) at each bookmark in view. Below the freq axis, show a #spectrum-bookmark-axis row of clickable amber ribbon labels (clip-path bookmark shape); clicking tunes the rig. Labels auto-appear / collapse as bookmarks scroll in and out of view. Server: reject POST/PUT with 409 Conflict when another bookmark already exists at the requested freq_hz (BookmarkStore::freq_taken helper). Client: bmFetch() triggers a spectrum redraw so markers appear immediately on load without requiring a tab visit first. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Stan Grams --- .../trx-frontend-http/assets/web/app.js | 83 +++++++++++++++++++ .../trx-frontend-http/assets/web/index.html | 1 + .../assets/web/plugins/bookmarks.js | 7 ++ .../trx-frontend-http/assets/web/style.css | 44 ++++++++++ .../trx-frontend/trx-frontend-http/src/api.rs | 10 +++ .../trx-frontend-http/src/bookmarks.rs | 7 ++ 6 files changed, 152 insertions(+) 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 c88802b..3c99ee5 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 @@ -4249,10 +4249,93 @@ function drawSpectrum(data) { ctx.restore(); } + // ── Bookmark frequency markers ───────────────────────────────────────────── + const visBookmarks = Array.isArray(window.bmList) + ? window.bmList.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz) + : []; + if (visBookmarks.length > 0) { + ctx.save(); + // Thin amber vertical line from top of canvas down to the ribbon + const BM_RIBBON_H = 14 * dpr; // height of the bookmark ribbon shape + const BM_RIBBON_W = 8 * dpr; // half-width of the ribbon + ctx.strokeStyle = "rgba(246,173,85,0.70)"; + ctx.lineWidth = 1 * dpr; + for (const bm of visBookmarks) { + const x = hzToX(bm.freq_hz); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, H - BM_RIBBON_H); + ctx.stroke(); + } + // Bookmark ribbon shape: rectangle with V-notch cut from the bottom + for (const bm of visBookmarks) { + const x = hzToX(bm.freq_hz); + const top = H - BM_RIBBON_H; + const bot = H; + const notchDepth = 4 * dpr; // depth of the V notch + ctx.fillStyle = "rgba(246,173,85,0.92)"; + ctx.strokeStyle = "rgba(180,100,20,0.60)"; + ctx.lineWidth = 0.75 * dpr; + ctx.beginPath(); + ctx.moveTo(x - BM_RIBBON_W, top); + ctx.lineTo(x + BM_RIBBON_W, top); + ctx.lineTo(x + BM_RIBBON_W, bot - notchDepth); + ctx.lineTo(x, bot); // V notch point + ctx.lineTo(x - BM_RIBBON_W, bot - notchDepth); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } + ctx.restore(); + } + updateSpectrumFreqAxis(range); + updateBookmarkAxis(range); drawSignalOverlay(); } +function updateBookmarkAxis(range) { + const axisEl = document.getElementById("spectrum-bookmark-axis"); + const freqAxisEl = document.getElementById("spectrum-freq-axis"); + if (!axisEl) return; + + const visBookmarks = Array.isArray(window.bmList) + ? window.bmList.filter((bm) => bm.freq_hz >= range.visLoHz && bm.freq_hz <= range.visHiHz) + : []; + + const hasVisible = visBookmarks.length > 0; + axisEl.classList.toggle("bm-axis-visible", hasVisible); + if (freqAxisEl) freqAxisEl.classList.toggle("bm-axis-open", hasVisible); + axisEl.innerHTML = ""; + if (!hasVisible) return; + + const axisWidth = axisEl.clientWidth || 0; + const edgePad = 6; + for (const bm of visBookmarks) { + const frac = (bm.freq_hz - range.visLoHz) / range.visSpanHz; + const span = document.createElement("span"); + span.textContent = bm.name; + span.title = + bm.name + + " \u2014 " + + (typeof bmFmtFreq === "function" ? bmFmtFreq(bm.freq_hz) : bm.freq_hz + "\u202fHz"); + span.dataset.bmId = bm.id; + span.addEventListener("click", () => { + if (typeof bmApply === "function") bmApply(bm); + }); + axisEl.appendChild(span); + if (axisWidth > 0) { + const labelWidth = span.offsetWidth || 0; + const minCenter = edgePad + labelWidth / 2; + const maxCenter = axisWidth - edgePad - labelWidth / 2; + const clampedCenter = Math.max(minCenter, Math.min(maxCenter, frac * axisWidth)); + span.style.left = clampedCenter + "px"; + } else { + span.style.left = (frac * 100).toFixed(2) + "%"; + } + } +} + function updateSpectrumFreqAxis(range) { if (!spectrumFreqAxis) return; const spanHz = range.visSpanHz; 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 6097b70..f30a37e 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 @@ -82,6 +82,7 @@
+
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 eaec54c..24a738f 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 @@ -46,6 +46,7 @@ async function bmFetch(categoryFilter) { bmSyncAccess(); bmRender(bmList); bmRefreshCategoryFilter(categoryFilter); + if (typeof scheduleSpectrumDraw === "function") scheduleSpectrumDraw(); } async function bmRefreshCategoryFilter(keepValue) { @@ -197,6 +198,9 @@ async function bmSave(e) { } if (!resp.ok) { const text = await resp.text(); + if (resp.status === 409) { + throw new Error("A bookmark for that frequency already exists."); + } throw new Error(text || "HTTP " + resp.status); } bmCloseForm(); @@ -294,4 +298,7 @@ async function bmApply(bm) { await bmDelete(delBtn.dataset.bmId); } }); + + // Pre-load bookmarks so spectrum markers are visible immediately. + bmFetch(""); })(); 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 9419e5e..c23585e 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 @@ -1516,6 +1516,50 @@ button:focus-visible, input:focus-visible, select:focus-visible { top: 2px; font-weight: 700; } +#spectrum-freq-axis.bm-axis-open { + border-radius: 0; + border-bottom-color: transparent; +} +#spectrum-bookmark-axis { + position: relative; + height: 0; + overflow: hidden; + width: 100%; + font-size: 0.68rem; + background: var(--bg-secondary); + border-radius: 0 0 6px 6px; + border: 1px solid var(--border); + border-top: none; + transition: height 80ms ease; +} +#spectrum-bookmark-axis.bm-axis-visible { + height: 22px; +} +#spectrum-bookmark-axis span { + position: absolute; + transform: translateX(-50%); + white-space: nowrap; + top: 3px; + cursor: pointer; + font-weight: 600; + font-size: 0.66rem; + /* amber bookmark-ribbon appearance */ + background: rgba(246,173,85,0.18); + color: #c07320; + border: 1px solid rgba(246,173,85,0.55); + border-radius: 3px 3px 0 0; + padding: 1px 5px 0; + /* clip the bottom into a V-notch bookmark shape */ + clip-path: polygon(0 0, 100% 0, 100% 70%, 50% 100%, 0 70%); + max-width: 110px; + overflow: hidden; + text-overflow: ellipsis; + line-height: 1.5; +} +#spectrum-bookmark-axis span:hover { + background: rgba(246,173,85,0.38); + color: #7a4a00; +} #spectrum-tooltip { display: none; position: absolute; 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 7392bb8..ddd67bd 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 @@ -735,6 +735,11 @@ pub async fn create_bookmark( auth_state: web::Data, ) -> Result { require_control(&req, &auth_state)?; + if store.freq_taken(body.freq_hz, None) { + return Err(actix_web::error::ErrorConflict( + "a bookmark for that frequency already exists", + )); + } let bm = crate::server::bookmarks::Bookmark { id: gen_bookmark_id(), name: body.name.clone(), @@ -764,6 +769,11 @@ pub async fn update_bookmark( ) -> Result { require_control(&req, &auth_state)?; let id = path.into_inner(); + if store.freq_taken(body.freq_hz, Some(&id)) { + return Err(actix_web::error::ErrorConflict( + "a bookmark for that frequency already exists", + )); + } let bm = crate::server::bookmarks::Bookmark { id: id.clone(), name: body.name.clone(), 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 9aec2b2..36f277f 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 @@ -91,4 +91,11 @@ impl BookmarkStore { let mut db = self.db.write().unwrap_or_else(|e| e.into_inner()); db.rem(&format!("bm:{id}")).unwrap_or(false) } + + /// 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.map_or(true, |ex| bm.id != ex) + }) + } }