[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:
@@ -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;
|
||||
|
||||
@@ -82,6 +82,7 @@
|
||||
<canvas id="spectrum-canvas"></canvas>
|
||||
<div id="spectrum-tooltip"></div>
|
||||
<div id="spectrum-freq-axis"></div>
|
||||
<div id="spectrum-bookmark-axis"></div>
|
||||
</div>
|
||||
<div id="spectrum-controls">
|
||||
<div id="spectrum-bw-row">
|
||||
|
||||
@@ -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("");
|
||||
})();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -735,6 +735,11 @@ pub async fn create_bookmark(
|
||||
auth_state: web::Data<crate::server::auth::AuthState>,
|
||||
) -> Result<HttpResponse, Error> {
|
||||
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<HttpResponse, Error> {
|
||||
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(),
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user