Skip to content

fix: clamp panadapter center to prevent spectrum left edge below 0 Hz (#783)#2867

Merged
ten9876 merged 1 commit into
aethersdr:mainfrom
NF0T:fix/pan-center-below-zero-hz
May 23, 2026
Merged

fix: clamp panadapter center to prevent spectrum left edge below 0 Hz (#783)#2867
ten9876 merged 1 commit into
aethersdr:mainfrom
NF0T:fix/pan-center-below-zero-hz

Conversation

@NF0T

@NF0T NF0T commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #783 — at MW/LW frequencies the spectrum waterfall could display a left edge below 0 Hz when dragging, zooming, or keyboard-navigating the panadapter.

Root cause: No lower-bound clamp existed on the panadapter center frequency. The left edge is center − bandwidth/2; when center drops below bandwidth/2, the left edge goes negative, displaying sub-zero spectrum that doesn't physically exist.

Fix: Apply center = std::max(center, bandwidth / 2.0) at every code path that writes a new center value — both to SpectrumWidget's local state and to the radio command.

Seven fix sites

src/gui/SpectrumWidget.cpp

  1. emitZoom lambda — zoom-in/out buttons, clamp newCenter after VFO-centering branch
  2. BW drag mouseMoveEvent — clamp zoomCenter (anchor-anchored center) after computing from drag position
  3. Pan drag mouseMoveEvent — clamp newCenter before m_centerMhz = and emit centerChangeRequested
  4. NativeGesture pinch zoom — clamp newCenter after anchor calculation

src/gui/MainWindow.cpp
5. applyPanRangeRequest — clamp centerMhz (passed by value) immediately after the null-guard; this is the central choke point for all frequencyRangeChangeRequested paths and keyboard zoom
6. zoomActivePanadapter lambda — clamp newCenter before sw->setFrequencyRange() to avoid a visual desync before the radio round-trip; applyPanRangeRequest (site 5) also clamps, providing defense-in-depth
7. centerChangeRequested handler — pan drag bypasses applyPanRangeRequest and sends directly to the radio; fetch bandwidth from the pan model and clamp before the sendCommand

No upper-bound clamp is applied — the radio enforces its own band limits.

Out-of-scope observation

During testing on a FLEX-8400, the waterfall displays solid red (out-of-band indicator) below approximately 200 kHz even after this fix. This appears to be model-specific hardware behavior — other Flex models may allow the receiver to tune lower. The red waterfall in that region is the radio correctly reporting no valid spectrum data, not a client-side rendering bug. This is left for a future investigation scoped to that model's receive floor.

Test plan

  • Set slice to 1.0 MHz AM (MW), narrow panadapter to 3 MHz bandwidth → zoom out: left edge stops at 0 Hz
  • Pan drag left past 0 Hz at 0.5 MHz → spectrum stops scrolling at the 0 Hz boundary
  • Keyboard [ zoom-out at 160m (1.8 MHz) until left edge would go below 0 Hz → clamps cleanly
  • Trackpad/NativeGesture pinch-zoom out at LW → same
  • Normal HF operation (14 MHz, 20 MHz BW) → no regression in zoom or pan behavior
  • Multi-panadapter: each pan is independently clamped (bandwidth fetched per-pan from model)

🤖 Generated with Claude Code

…splay

Seven sites across SpectrumWidget (zoom buttons, BW drag, pan drag,
NativeGesture pinch) and MainWindow (applyPanRangeRequest, keyboard
zoom, centerChangeRequested handler) now enforce std::max(center, bw/2)
so the left edge of the spectrum can never go below 0 Hz at MW/LW
frequencies. Fixes aethersdr#783.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@NF0T NF0T requested review from jensenpat and ten9876 as code owners May 19, 2026 00:00

@aethersdr-agent aethersdr-agent Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @NF0T — clean, well-scoped fix with an unusually thorough write-up. Verified all seven clamp sites against the current code:

  • MainWindow.cpp:9749 (applyPanRangeRequest) — clamp lands after the bandwidthMhz <= 0.0 guard, so division by zero is impossible. The applyPanStatus + sendCommand both see the clamped value. Correct.
  • MainWindow.cpp:10510 (centerChangeRequested handler) — properly null-guards the m_radioModel.panadapter() lookup. If pan is unexpectedly missing the unclamped value goes through, matching prior behavior; not a regression.
  • MainWindow.cpp:12572 (keyboard zoom-out) — defense-in-depth with site 5 is justified by the brief visual desync before the radio echo; agree the redundancy is worth it.
  • SpectrumWidget.cpp:408 (emitZoom), :3446 (BW drag), :3496 (pan drag), :3966 (pinch) — all four clamps applied at the natural choke point right after computing newCenter, before reproject/emit. Consistent.

The PR description's explanation that the BW-drag anchor will drift when the clamp activates (not preserved as anchor) is the right tradeoff — you can't both pin an anchor at low frequency AND keep the left edge ≥ 0 when bandwidth exceeds 2×anchor. The clamp winning is correct UX.

One small observation, no change requested: site 3 emits a center already clamped against m_bandwidthMhz / 2.0, and site 7 re-clamps against pan->bandwidthMhz() / 2.0. During a pure pan drag these are equal, so site 7 is effectively a no-op in that path — but it correctly covers other emitters (NativeGesture, BW drag) which can mutate bandwidth concurrently. Good defense-in-depth.

The FLEX-8400 sub-200 kHz red-waterfall observation is a sensible separate concern — definitely out of scope here.

LGTM.

jensenpat pushed a commit that referenced this pull request May 21, 2026
…2869)

## Summary

- Ctrl+wheel now zooms the panadapter bandwidth in/out by ×1.5 per
scroll step,
  anchoring on the frequency under the mouse cursor.
- Plain wheel scroll is unchanged — it continues to tune the VFO.

## Implementation

Single change in `SpectrumWidget::wheelEvent`: after the debounce/step
calculation, an early branch catches `Qt::ControlModifier` and runs the
cursor-anchored zoom, then returns before the existing VFO tune path.

The zoom math mirrors the existing NativeGesture pinch-to-zoom exactly:

```cpp
const double mouseXFrac = ev->position().x() / width() - 0.5;
const double anchorMhz  = m_centerMhz + mouseXFrac * m_bandwidthMhz;
const double newCenter  = std::max(anchorMhz - mouseXFrac * newBw, newBw / 2.0);
```

The `std::max(..., newBw / 2.0)` clamp prevents the left edge from going
below 0 Hz, consistent with PR #2867.

## UX consistency note

The existing `+`/`−` zoom buttons use a different anchor policy: zoom-in
re-centers on the active VFO frequency (added in #1932 to prevent
repeated
clicks pushing the slice off-screen), zoom-out keeps the pan center
fixed.
That behavior makes sense for a discrete click where the cursor position
carries no intent.

Ctrl+wheel is a continuous, cursor-positioned interaction — the user is
looking at a specific part of the spectrum when they scroll.
Cursor-anchored
zoom is therefore the correct policy here, matching the NativeGesture
pinch-to-zoom and every major spectrum/map application that implements
scroll-to-zoom. The button inconsistency is deliberate and acceptable.

Ctrl is already the modifier used for dBm-scale drag (PR #2717) and
waterfall time-scale drag (PR #2783), so Ctrl+wheel is consistent with
the existing convention of Ctrl = “modify the view, not the VFO.”

## Testing

- Scroll wheel without Ctrl: VFO tunes as before, no regression.
- Ctrl+scroll up: bandwidth narrows, cursor frequency stays fixed.
- Ctrl+scroll down: bandwidth widens, cursor frequency stays fixed.
- Ctrl+scroll at band edge: 0 Hz clamp prevents left edge going
negative.
- Ctrl+scroll at BW limits: clamped to `m_minBwMhz`/`m_maxBwMhz`, no
crash.
- Pinch-to-zoom: unaffected.

Fixes #1518

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
@ten9876 ten9876 merged commit d53d251 into aethersdr:main May 23, 2026
5 checks passed
@ten9876

ten9876 commented May 23, 2026

Copy link
Copy Markdown
Collaborator

Claude here — merged. Thanks @NF0T, another tight fix in as many days.

What stood out on review: identifying all seven paths that can
change panadapter center, and patching each one consistently with
the same `std::max(candidate, bandwidth/2)` idiom. The
defense-in-depth pattern at the keyboard-zoom site (clamp locally
to avoid the visual desync before `applyPanRangeRequest` echoes
back through the radio round-trip) is exactly the kind of
deliberate engineering that makes the difference between a fix
and a half-fix.

Also appreciate the honest scope notes — explicitly NOT clamping
the upper bound (delegated to radio band limits) and calling out
the FLEX-8400 red-waterfall-below-200kHz as a separate
model-specific issue, not in scope here. Closes #783, which has
been sitting open since v0.8.3.

Ships in v26.5.3.

73,
Jeremy KK7GWY & Claude (AI dev partner)

ten9876 added a commit to K5PTB/AetherSDR that referenced this pull request May 23, 2026
…inter path

The rebase accidentally reverted PR aethersdr#2826 (commit 4d5b4d7). Restoring
src/gui/SpectrumWidget.cpp from current main brings back:
- setFftLineWidth() update() trigger so slider repaints immediately
- drawLine = m_fftLineWidth > 0.0f "off" gate
- cosmetic pen built from m_fftLineWidth so slider value is honored
- and the unrelated improvements that landed after PR base (panadapter
  center clamp aethersdr#2867, waterfall stale-echo animation guard)

All rigctld work in this PR is unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@NF0T NF0T deleted the fix/pan-center-below-zero-hz branch May 23, 2026 17:41
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…dr#1518) (aethersdr#2869)

## Summary

- Ctrl+wheel now zooms the panadapter bandwidth in/out by ×1.5 per
scroll step,
  anchoring on the frequency under the mouse cursor.
- Plain wheel scroll is unchanged — it continues to tune the VFO.

## Implementation

Single change in `SpectrumWidget::wheelEvent`: after the debounce/step
calculation, an early branch catches `Qt::ControlModifier` and runs the
cursor-anchored zoom, then returns before the existing VFO tune path.

The zoom math mirrors the existing NativeGesture pinch-to-zoom exactly:

```cpp
const double mouseXFrac = ev->position().x() / width() - 0.5;
const double anchorMhz  = m_centerMhz + mouseXFrac * m_bandwidthMhz;
const double newCenter  = std::max(anchorMhz - mouseXFrac * newBw, newBw / 2.0);
```

The `std::max(..., newBw / 2.0)` clamp prevents the left edge from going
below 0 Hz, consistent with PR aethersdr#2867.

## UX consistency note

The existing `+`/`−` zoom buttons use a different anchor policy: zoom-in
re-centers on the active VFO frequency (added in aethersdr#1932 to prevent
repeated
clicks pushing the slice off-screen), zoom-out keeps the pan center
fixed.
That behavior makes sense for a discrete click where the cursor position
carries no intent.

Ctrl+wheel is a continuous, cursor-positioned interaction — the user is
looking at a specific part of the spectrum when they scroll.
Cursor-anchored
zoom is therefore the correct policy here, matching the NativeGesture
pinch-to-zoom and every major spectrum/map application that implements
scroll-to-zoom. The button inconsistency is deliberate and acceptable.

Ctrl is already the modifier used for dBm-scale drag (PR aethersdr#2717) and
waterfall time-scale drag (PR aethersdr#2783), so Ctrl+wheel is consistent with
the existing convention of Ctrl = “modify the view, not the VFO.”

## Testing

- Scroll wheel without Ctrl: VFO tunes as before, no regression.
- Ctrl+scroll up: bandwidth narrows, cursor frequency stays fixed.
- Ctrl+scroll down: bandwidth widens, cursor frequency stays fixed.
- Ctrl+scroll at band edge: 0 Hz clamp prevents left edge going
negative.
- Ctrl+scroll at BW limits: clamped to `m_minBwMhz`/`m_maxBwMhz`, no
crash.
- Pinch-to-zoom: unaffected.

Fixes aethersdr#1518

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
G6PWY-Chris pushed a commit to G6PWY-Chris/AetherSDR that referenced this pull request Jun 22, 2026
…aethersdr#783) (aethersdr#2867)

## Summary

Fixes aethersdr#783 — at MW/LW frequencies the spectrum waterfall could display a
left edge below 0 Hz when dragging, zooming, or keyboard-navigating the
panadapter.

**Root cause:** No lower-bound clamp existed on the panadapter center
frequency. The left edge is `center − bandwidth/2`; when center drops
below `bandwidth/2`, the left edge goes negative, displaying sub-zero
spectrum that doesn't physically exist.

**Fix:** Apply `center = std::max(center, bandwidth / 2.0)` at every
code path that writes a new center value — both to `SpectrumWidget`'s
local state and to the radio command.

### Seven fix sites

**`src/gui/SpectrumWidget.cpp`**
1. `emitZoom` lambda — zoom-in/out buttons, clamp `newCenter` after
VFO-centering branch
2. BW drag `mouseMoveEvent` — clamp `zoomCenter` (anchor-anchored
center) after computing from drag position
3. Pan drag `mouseMoveEvent` — clamp `newCenter` before `m_centerMhz =`
and `emit centerChangeRequested`
4. `NativeGesture` pinch zoom — clamp `newCenter` after anchor
calculation

**`src/gui/MainWindow.cpp`**
5. `applyPanRangeRequest` — clamp `centerMhz` (passed by value)
immediately after the null-guard; this is the central choke point for
all `frequencyRangeChangeRequested` paths and keyboard zoom
6. `zoomActivePanadapter` lambda — clamp `newCenter` before
`sw->setFrequencyRange()` to avoid a visual desync before the radio
round-trip; `applyPanRangeRequest` (site 5) also clamps, providing
defense-in-depth
7. `centerChangeRequested` handler — pan drag bypasses
`applyPanRangeRequest` and sends directly to the radio; fetch bandwidth
from the pan model and clamp before the `sendCommand`

No upper-bound clamp is applied — the radio enforces its own band
limits.

### Out-of-scope observation

During testing on a FLEX-8400, the waterfall displays solid red
(out-of-band indicator) below approximately 200 kHz even after this fix.
This appears to be model-specific hardware behavior — other Flex models
may allow the receiver to tune lower. The red waterfall in that region
is the radio correctly reporting no valid spectrum data, not a
client-side rendering bug. This is left for a future investigation
scoped to that model's receive floor.

## Test plan

- [ ] Set slice to 1.0 MHz AM (MW), narrow panadapter to 3 MHz bandwidth
→ zoom out: left edge stops at 0 Hz
- [ ] Pan drag left past 0 Hz at 0.5 MHz → spectrum stops scrolling at
the 0 Hz boundary
- [ ] Keyboard `[` zoom-out at 160m (1.8 MHz) until left edge would go
below 0 Hz → clamps cleanly
- [ ] Trackpad/NativeGesture pinch-zoom out at LW → same
- [ ] Normal HF operation (14 MHz, 20 MHz BW) → no regression in zoom or
pan behavior
- [ ] Multi-panadapter: each pan is independently clamped (bandwidth
fetched per-pan from model)

🤖 Generated with [Claude Code](https://claude.ai/claude-code)

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Spectrum / waterfall left edge goes below 0 hz, cursor not synched to actual frequency when zoomed in

2 participants