diff --git a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js index 5860ede2..3d41f224 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js +++ b/src/trx-client/trx-frontend/trx-frontend-http/assets/web/generated/app.js @@ -8588,7 +8588,8 @@ function updateBookmarkAxis(range) { updateSideBookmarkStack(leftSideEl, leftBookmarks, colorMap); updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap); const hasVisible = visBookmarks.length > 0; - axisEl.classList.toggle("bm-axis-visible", hasVisible); + axisEl.classList.add("bm-axis-visible"); + axisEl.classList.toggle("bm-axis-empty", !hasVisible); if (!hasVisible) { if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); @@ -9383,24 +9384,28 @@ function bandplanVisibleSegments(region, loHz, hiHz) { } return result; } -function _hideBandplanStrip() { +function _clearBandplanStrip(reserveSpace) { if (!bandplanStripEl) return; - bandplanStripEl.classList.remove("bp-visible"); - bandplanStripEl.replaceChildren(); - bandplanCacheKey = ""; + if (bandplanCacheKey) { + bandplanStripEl.replaceChildren(); + bandplanCacheKey = ""; + } + bandplanStripEl.classList.toggle("bp-visible", reserveSpace); + bandplanStripEl.classList.toggle("bp-empty", reserveSpace); } function updateBandplanStrip(range) { if (!bandplanStripEl) return; if (!range || bandplanRegion === "off" || !bandplanData) { - if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); + _clearBandplanStrip(false); return; } const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz); if (segments.length === 0) { - if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); + _clearBandplanStrip(true); return; } bandplanStripEl.classList.add("bp-visible"); + bandplanStripEl.classList.remove("bp-empty"); const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(","); const stripW = bandplanStripEl.clientWidth || 1; if (bandplanCacheKey !== newKey) { 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 4d3386d1..c74ed041 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 @@ -3967,6 +3967,11 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { overflow: hidden; border-bottom-color: var(--border-light, rgba(255,255,255,0.06)); } +/* No allocations in range. The height stays reserved — collapsing it moved + every element below by 18px each time tuning left a band. */ +#spectrum-bandplan-strip.bp-empty { + background: color-mix(in srgb, var(--border) 22%, transparent); +} .bp-segment { position: absolute; top: 0; @@ -4057,6 +4062,12 @@ body[data-operator-layout="broadcast"] #cw-bar-overlay { height: 38px; overflow: visible; } +/* The rail sits over the top of the overview plot, so it must not swallow + drags and clicks meant for it — only the chips themselves are targets. */ +#spectrum-bookmark-axis { pointer-events: none; } +#spectrum-bookmark-axis .spectrum-bookmark-chip { pointer-events: auto; } +/* Nothing bookmarked in range: the rail keeps its place but stays blank — + no caption over the plot. */ .spectrum-bookmark-chip { position: absolute; top: 2px; diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json index ccb5e9f0..d69fc920 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --project tsconfig.json && tsc --project tsconfig.worker.json", "lint": "eslint \"src/**/*.ts\" \"tests/**/*.mjs\" build.mjs --no-error-on-unmatched-pattern", "test": "node --test tests/*.test.mjs", - "test:browser": "node tests/browser-smoke.mjs", + "test:browser": "node tests/browser-smoke.mjs && node tests/spectrum-layout.mjs", "verify-generated": "npm run generate-types && npm run build && git diff --exit-code -- ../assets/web/generated src/api/generated.ts" }, "devDependencies": { diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts index 6f076953..08dadd36 100644 --- a/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/src/app.ts @@ -7761,7 +7761,12 @@ function updateBookmarkAxis(range: SpectrumRange) { updateSideBookmarkStack(rightSideEl, rightBookmarks, colorMap); const hasVisible = visBookmarks.length > 0; - axisEl.classList.toggle("bm-axis-visible", hasVisible); + // The rail is kept up with nothing in range — blank, no caption — so tuning + // across bands only swaps its contents rather than taking the strip itself + // away. This function only runs with a spectrum range in hand, so rigs + // without a spectrum never get it. + axisEl.classList.add("bm-axis-visible"); + axisEl.classList.toggle("bm-axis-empty", !hasVisible); if (!hasVisible) { if (axisEl.dataset.bmKey) { axisEl.replaceChildren(); axisEl.dataset.bmKey = ""; } @@ -8640,27 +8645,36 @@ function bandplanVisibleSegments(region: string, loHz: number, hiHz: number): Vi return result; } -function _hideBandplanStrip() { +// Empties the strip. `reserveSpace` keeps its height: the strip is in flow, so +// collapsing it on a range with no allocations shifted the whole page down by +// its height every time tuning crossed out of a band. Space stays reserved +// whenever a band plan could be drawn at all, and is only given back when the +// feature is off, has no data, or there is no spectrum to annotate. +function _clearBandplanStrip(reserveSpace: boolean) { if (!bandplanStripEl) return; - bandplanStripEl.classList.remove("bp-visible"); - bandplanStripEl.replaceChildren(); - bandplanCacheKey = ""; + if (bandplanCacheKey) { + bandplanStripEl.replaceChildren(); + bandplanCacheKey = ""; + } + bandplanStripEl.classList.toggle("bp-visible", reserveSpace); + bandplanStripEl.classList.toggle("bp-empty", reserveSpace); } function updateBandplanStrip(range: SpectrumRange | null) { if (!bandplanStripEl) return; if (!range || bandplanRegion === "off" || !bandplanData) { - if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); + _clearBandplanStrip(false); return; } const segments = bandplanVisibleSegments(bandplanRegion, range.visLoHz, range.visHiHz); if (segments.length === 0) { - if (bandplanStripEl.classList.contains("bp-visible")) _hideBandplanStrip(); + _clearBandplanStrip(true); return; } bandplanStripEl.classList.add("bp-visible"); + bandplanStripEl.classList.remove("bp-empty"); const newKey = bandplanRegion + ":" + (bandplanShowLabels ? "L" : "N") + ":" + segments.map((s) => s.low_hz + "-" + s.high_hz).join(","); diff --git a/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs new file mode 100644 index 00000000..f88ad397 --- /dev/null +++ b/src/trx-client/trx-frontend/trx-frontend-http/frontend/tests/spectrum-layout.mjs @@ -0,0 +1,127 @@ +// SPDX-FileCopyrightText: 2026 Stan Grams +// +// SPDX-License-Identifier: GPL-2.0-or-later + +// Geometry of the spectrum area while tuning. Everything above the spectrum is +// driven by what happens to be in the visible range — band plan allocations, +// bookmarks — and the strips that show them used to appear and disappear with +// it, so tuning across a band edge moved the whole page under the operator's +// cursor. Nothing else in the suite serves a rig with a spectrum. + +import assert from "node:assert/strict"; +import { chromium } from "playwright-core"; +import { startBrowser, startWebFixture } from "./web-fixture.mjs"; + +/* global document */ + +const BOOKMARKS = [ + { id: "b1", name: "40m FT8", freq_hz: 7074000, mode: "DIG", category: "Digital", comment: "", locator: "" }, + { id: "b2", name: "40m CW", freq_hz: 7030000, mode: "CW", category: "", comment: "", locator: "" }, +]; +const BANDPLAN = { + iaru1: { + bands: [{ + name: "40m", + low_hz: 7000000, + high_hz: 7200000, + segments: [ + { low_hz: 7000000, high_hz: 7040000, mode: "CW", label: "CW" }, + { low_hz: 7040000, high_hz: 7200000, mode: "All", label: "All modes" }, + ], + }], + }, +}; +// 40m has both bookmarks and allocations; 20m has neither. +const BAND_WITH_CONTENT = 7074000; +const BAND_WITHOUT_CONTENT = 14074000; + +const fixture = await startWebFixture({ + spectrum: true, + bookmarks: BOOKMARKS, + bandplan: BANDPLAN, + bandplanEnabled: true, +}); +const { browser, page, runtimeErrors } = await startBrowser(chromium); + +function readGeometry() { + const top = (selector) => { + const el = document.querySelector(selector); + return el ? Math.round(el.getBoundingClientRect().top) : null; + }; + const axis = document.getElementById("spectrum-bookmark-axis"); + const strip = document.getElementById("spectrum-bandplan-strip"); + return { + chips: axis.querySelectorAll(".spectrum-bookmark-chip").length, + axisEmpty: axis.classList.contains("bm-axis-empty"), + stripReserved: strip.classList.contains("bp-visible"), + stripEmpty: strip.classList.contains("bp-empty"), + overviewTop: top(".overview-strip"), + spectrumTop: top("#spectrum-panel"), + controlsTop: top(".controls-row"), + footerTop: top(".footer"), + docHeight: document.documentElement.scrollHeight, + }; +} + +const layoutOf = (geometry) => ({ + overviewTop: geometry.overviewTop, + spectrumTop: geometry.spectrumTop, + controlsTop: geometry.controlsTop, + footerTop: geometry.footerTop, + docHeight: geometry.docHeight, +}); + +async function tuneTo(hz) { + fixture.setCenterHz(hz); + await page.waitForTimeout(900); + return page.evaluate(readGeometry); +} + +try { + await page.setViewportSize({ width: 1600, height: 950 }); + await page.goto(fixture.origin, { waitUntil: "domcontentloaded" }); + await page.locator("#content").waitFor({ state: "visible" }); + await page.locator("#spectrum-panel").waitFor({ state: "visible" }); + await page.waitForTimeout(1500); + + const populated = await tuneTo(BAND_WITH_CONTENT); + assert.equal(populated.chips, BOOKMARKS.length, `expected both bookmarks, saw ${populated.chips}`); + assert.equal(populated.axisEmpty, false, "bookmark rail claims to be empty with chips in it"); + assert.equal(populated.stripReserved, true, "band plan strip is missing on a band with allocations"); + assert.equal(populated.stripEmpty, false, "band plan strip claims to be empty with segments in it"); + + const bare = await tuneTo(BAND_WITHOUT_CONTENT); + assert.equal(bare.chips, 0, `expected no bookmarks on ${BAND_WITHOUT_CONTENT}Hz, saw ${bare.chips}`); + assert.equal(bare.axisEmpty, true, "bookmark rail should show its placeholder"); + assert.equal(bare.stripReserved, true, "band plan strip gave its height back"); + assert.equal(bare.stripEmpty, true, "band plan strip should be marked empty"); + + // The point of both placeholders: tuning must not move anything. + assert.deepEqual(layoutOf(bare), layoutOf(populated), + `tuning off the band moved the page: ${JSON.stringify(populated)} -> ${JSON.stringify(bare)}`); + + const back = await tuneTo(BAND_WITH_CONTENT); + assert.equal(back.chips, BOOKMARKS.length, "bookmarks did not come back"); + assert.deepEqual(layoutOf(back), layoutOf(populated), "tuning back moved the page"); + + // The rail covers the top of the overview, so only the chips may take + // pointer events — the rest has to fall through to the plot behind it. + const hits = await page.evaluate(() => { + const chip = document.querySelector("#spectrum-bookmark-axis .spectrum-bookmark-chip"); + const chipRect = chip.getBoundingClientRect(); + const axisRect = document.getElementById("spectrum-bookmark-axis").getBoundingClientRect(); + const onChip = document.elementFromPoint(chipRect.left + chipRect.width / 2, chipRect.top + chipRect.height / 2); + const besideChip = document.elementFromPoint(axisRect.right - 30, axisRect.top + 10); + return { + chip: onChip?.closest(".spectrum-bookmark-chip") ? "chip" : (onChip?.id || onChip?.tagName), + besideChip: besideChip?.id || besideChip?.tagName, + }; + }); + assert.equal(hits.chip, "chip", `chip is not clickable, hit ${hits.chip}`); + assert.equal(hits.besideChip, "overview-canvas", `rail swallows events, hit ${hits.besideChip}`); + + assert.deepEqual(runtimeErrors, []); +} finally { + await browser.close(); + await fixture.close(); +}