Skip to content

Fix derived-plot alignment and cursor stability under zoom/EOF#3

Open
Theleifless wants to merge 17 commits into
jacobagilbert:mainfrom
Theleifless:bugfix/derived-plot-alignment-and-cursor-stability
Open

Fix derived-plot alignment and cursor stability under zoom/EOF#3
Theleifless wants to merge 17 commits into
jacobagilbert:mainfrom
Theleifless:bugfix/derived-plot-alignment-and-cursor-stability

Conversation

@Theleifless

Copy link
Copy Markdown

Four related bugs that surface together when inspecting short SigMF captures
with a derived plot (amplitude / IFR / ...) panel added beneath the spectrogram.
Each commit is self-contained.

1. TracePlot drew on a different x-scale than the spectrogram

When viewRange got clamped to a small file, TracePlot::paintMid computed
samplesPerColumn = sampleRange.length() / rect.width() while the spectrogram
used fftSize / zoomLevel. The two panels drew the same samples at different
x positions. PlotView now pushes its global samples-per-column into each
TracePlot via a new setSamplesPerColumn setter (with a fallback to the
legacy local computation for any non-PlotView caller).

2. TracePlot vanished at high zoom

samplesPerTile could exceed the file size, InputSource::getSamples
returned nullptr, and drawTile returned without emitting an image — so
QPixmapCache never got a tile and the panel stayed transparent. Now drawTile
clamps the request to what the source actually has, shrinks the draw rect
proportionally so the visible samples occupy their natural pixel range, and
always emits the image.

3. Spectrogram clipped fftSize/2 samples before EOF

The centred FFT couldn't be computed for the last fftSize/2 samples;
getLine filled the column with -inf. The spectrogram visibly ended short
of the trace plots' right edge. getLine now zero-pads the partial buffer
at EOF so the spectrogram and trace plots stop at the same x-pixel.

4. Cursors lost their sample range under zoom

Cursors store viewport-pixel positions; setFFTAndZoom changed
samplesPerColumn without re-anchoring them, so the "Symbol rate" / "Period"
readout drifted just because the user zoomed. setFFTAndZoom now snapshots
the cursors' absolute sample range at the top and re-places the cursors on
the new pixel grid at the end, after updateView() has settled the
scrollbars.

Test plan

  • Visual: load a short SigMF capture (e.g. a 3 ms extracted pulse at 1 MS/s),
    right-click → Add derived plot → Add amplitude, Ctrl-scroll to zoom from
    1× to maximum. Both panels stay aligned at every zoom level and the trace
    remains visible end-to-end.
  • Cursor stability: enable cursors over a known symbol period, note the
    "Symbol rate" / "Period" readout, zoom in and out. Readout stays put.
  • Spectrogram EOF: scroll to the right edge of the file; the spectrogram now
    reaches the same x-pixel as the derived plot.

miek and others added 17 commits November 30, 2025 23:41
TracePlot::paintMid was computing its own samples-per-column locally
from sampleRange.length() / rect.width(). When the view range gets
clamped to a small file (smaller than the natural fftSize/zoomLevel
span of the viewport), the spectrogram still uses fftSize/zoomLevel
while TracePlot stretches its samples across the whole viewport --
so the two panels visibly disagree on the time axis.

Push the view's samples-per-column into each TracePlot (via a new
TracePlot::setSamplesPerColumn) when it is added and whenever
setFFTAndZoom recomputes the time scale. Falls back to the legacy
local computation when no value has been set so existing standalone
users keep their previous behaviour.

Reproduces with any short SigMF capture at non-default zoom: open
the file, right-click -> Add derived plot -> Add amplitude, then
zoom in. Before this change the trace fills the viewport while the
spectrogram occupies a fraction of it; after, both panels stop at
the same x-pixel.
InputSource::getSamples returns nullptr when start + length exceeds
sampleCount. TracePlot::drawTile would then bail without emitting an
image, so the tile remained fully transparent. Symptom: when zoomed
in, samplesPerTile can be larger than the entire file, so even the
first tile gets a null buffer and the trace plot vanishes entirely.

The spectrogram handles this exact case by filling with -inf (renders
black); TracePlot was the outlier. Clamp the request to what is
actually available on the source, shrink the draw rect proportionally
so the visible samples occupy their natural pixel range, and always
emit the image so QPixmapCache caches it (no busy-loop retry).
getLine() centred the FFT on `sample` but bailed to a column of -inf
whenever first_sample + fftSize exceeded the file size. That made the
spectrogram visibly stop fftSize/2 samples short of the trace plots'
right edge, since the last fully-centred FFT lives at sample
EOF - fftSize/2.

Get whatever samples are available, zero-pad the rest of the FFT
buffer, then run the same windowing / FFT / dB pipeline. The last
columns get slight spectral leakage from the padding boundary, but
the spectrogram now ends at the same x-pixel as the trace plot and
the user can actually see what's happening at the end of a short
capture.

The pre-existing head-of-file clamp (and its -inf bail-out when
first_sample >= EOF) is preserved.
The cursors store their position in viewport pixels. setFFTAndZoom
changes samplesPerColumn but did not re-anchor the cursors, so the
same pixel pair now spanned a different sample count. The
"Symbol rate" / "Period" readouts in the Time selection panel would
silently drift just because the user zoomed -- a sharp foot-gun when
the readout is being used to measure something.

Snapshot the cursor's absolute sample range at the top of
setFFTAndZoom and re-place the cursors on the new pixel grid at the
end, after updateView() has already settled the scrollbars. The
final write also overrides any stale value left by the cursorsMoved
signal cascade that fires when setSelection() temporarily reads back
the rounded-to-integer-column pixel position.
# Conflicts:
#	CMakeLists.txt
#	src/spectrogramplot.h
Annotations without a presentation:color field yield an empty QString.
The color-format check called sigmf_color.at(0) before testing length;
under Qt6 an empty QString has a null data pointer, so at(0) dereferenced
null and crashed when opening such a recording. Check length() == 9 first
so the && short-circuits before at(0).

Co-Authored-By: Claude Opus 4.8 (1M context) <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.

2 participants