[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 <noreply@anthropic.com>
Signed-off-by: Stan Grams <sjg@haxx.space>
This commit is contained in:
2026-03-01 19:39:09 +01:00
parent e80f71ced6
commit 587b06c6d8
6 changed files with 152 additions and 0 deletions
@@ -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;