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)
+ })
+ }
}