Files
Jungfraujoch/viewer/charts/JFJochDatasetInfoChartView.cpp
T
leonarski_fandClaude Fable 5 53d6aa4ddf
Build Packages / Create release (push) Successful in 17s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m35s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m40s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m1s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m35s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m9s
Build Packages / build:windows:nocuda (push) Successful in 17m10s
Build Packages / build:windows:cuda (push) Successful in 19m39s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 14m39s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m5s
Build Packages / build:rugnux:windows (push) Successful in 10m49s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m3s
Build Packages / Generate python client (push) Successful in 44s
Build Packages / Build documentation (push) Successful in 1m15s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 19m33s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m18s
Build Packages / build:rpm (rocky8) (push) Successful in 17m17s
Build Packages / build:rpm (rocky9) (push) Successful in 18m28s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m45s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 19m38s
Build Packages / Unit tests (push) Successful in 1h38m36s
viewer: ring labels that stay in view, and an inspector that reads at a glance
The resolution-ring label was placed at one of four fixed azimuths with
its corner on the ring line, in a hardcoded 16 pt Arial, in the ring's
own magenta: it clipped at the viewport edge, ignored the font size,
and vanished into busy patterns. It now walks the ring's cached trace,
centres on the first point where the whole text fits in the viewport
(failing that, clamps the nearest visible point inside), keeps a
constant on-screen size of 1.2x the application font, and is cased like
the spot markers so it reads on any colour map. In the same function
visibleRect now maps viewport()->rect(), which is what mapToScene
expects.

The dataset plot's angle axis snaps its ticks to round angles -
multiples of 90 degrees when the sweep is wide enough - instead of
whatever angle the evenly-spaced image ticks landed on. The inspector
prints the indexed cell as two aligned rows (alpha below a, beta below
b, gamma below c), mosaicity with two decimals, and "No lattice" in
grey, keeping red for actual trouble. The armed "Analyze image"
button stays navy and is marked by a coral outline instead of turning
into a differently-coloured button. The dataset navigation keys leave
the image's keyPressEvent - the window-wide filter of the previous
commit sees them first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vRwzF8PLaaa2novAKYorq
2026-09-13 20:20:54 +02:00

964 lines
37 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include <QMenu>
#include <QApplication>
#include <QClipboard>
#include <QCategoryAxis>
#include <QtCharts/QLegendMarker>
#include <QGraphicsLayout>
#include "JFJochDatasetInfoChartView.h"
namespace {
// Qt Charts draws its curves 2 px wide, which is hard to follow across a desk. Take the weight
// from the font, so a zoomed-up UI gets a proportionally heavier line (~3 px at the default font).
void setSeriesPenWidth(QLineSeries *line, int font_height) {
QPen pen = line->pen();
pen.setWidthF(font_height / 5.0);
line->setPen(pen);
}
}
JFJochDatasetInfoChartView::JFJochDatasetInfoChartView(QWidget *parent)
: QChartView(new QChart(), parent) {
chart()->legend()->hide();
// Reclaim Qt Charts' outer layout padding so the axis labels keep their room even when the dock
// is short (otherwise they are the first thing Qt drops); the inner margins are set per rebuild.
chart()->layout()->setContentsMargins(0, 0, 0, 0);
chart()->setBackgroundRoundness(0);
setRenderHint(QPainter::Antialiasing);
// setRubberBand(QChartView::RubberBand::RectangleRubberBand);
setMouseTracking(true);
m_hoverLoadTimer = new QTimer(this);
m_hoverLoadTimer->setSingleShot(true);
connect(m_hoverLoadTimer, &QTimer::timeout, this, &JFJochDatasetInfoChartView::onHoverLoadTimeout);
}
void JFJochDatasetInfoChartView::setImage(int64_t val) {
if (!currentSeries || currentSeries->chart() != chart())
return;
curr_image = val;
currentSeries->clear();
if (values.empty() || val < 0 ||
val >= static_cast<int64_t>(values.size()))
return;
// For binning > 1, show the binned mean at bin center
if (binning > 1) {
const int64_t nBins =
static_cast<int64_t>(values.size()) / binning;
if (nBins <= 0)
return;
int64_t binIdx = val / binning;
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
double sum = 0.0;
int64_t count = 0;
for (int64_t b = 0; b < binning; ++b) {
const int64_t idx = binIdx * binning + b;
if (idx >= static_cast<int64_t>(values.size()))
break;
const double v = values[static_cast<size_t>(idx)];
if (std::isfinite(v)) {
sum += v;
++count;
}
}
if (count > 0) {
const double mean = sum / static_cast<double>(count);
if (std::isfinite(mean)) {
const double centerX =
(static_cast<double>(binIdx) + 0.5) * static_cast<double>(binning);
currentSeries->append(centerX, mean);
}
}
} else {
// binning == 1: original behavior, per-image value
if (std::isfinite(values[curr_image])) {
const double disp = values[curr_image];
if (std::isfinite(disp))
currentSeries->append(curr_image, disp);
}
}
}
void JFJochDatasetInfoChartView::mousePressEvent(QMouseEvent *event) {
if (event->button() == Qt::LeftButton) {
if (values.empty()) {
QChartView::mousePressEvent(event);
return;
}
const QPointF clickedPoint = event->pos();
const QPointF chartCoord = chart()->mapToValue(clickedPoint, series);
const double xVal = chartCoord.x();
if (!std::isfinite(xVal) || xVal < 0.0 ||
xVal > static_cast<double>(values.size() - 1)) {
QChartView::mousePressEvent(event);
return;
}
int64_t selectedIdx = 0;
if (binning <= 1) {
// Original behavior: pick nearest frame index
selectedIdx = std::lround(xVal);
} else {
// Binned mode: pick bin index from x, then representative frame
const int64_t nBins =
static_cast<int64_t>(values.size()) / binning;
if (nBins <= 0) {
QChartView::mousePressEvent(event);
return;
}
int64_t binIdx =
static_cast<int64_t>(std::floor(xVal / static_cast<double>(binning)));
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
int64_t centerIdx = binIdx * binning + binning / 2;
if (centerIdx >= static_cast<int64_t>(values.size()))
centerIdx = static_cast<int64_t>(values.size()) - 1;
selectedIdx = centerIdx;
}
if (selectedIdx >= 0 &&
selectedIdx < static_cast<int64_t>(values.size())) {
emit imageSelected(selectedIdx);
}
}
QChartView::mousePressEvent(event); // Call the base implementation
}
void JFJochDatasetInfoChartView::resetZoom() {
chart()->zoomReset();
}
void JFJochDatasetInfoChartView::loadValues(const std::vector<float> &input, int64_t image, bool one_over_d2,
const JFJochReaderDataset *dataset, const QString &primaryName,
std::vector<NamedSeries> overlays, const QColor &primaryColor,
std::vector<float> primaryX, int64_t fullRange,
std::optional<NamedSeries> secondary) {
m_yOneOverD = one_over_d2;
primaryName_ = primaryName;
primary_color_ = primaryColor;
primary_x_ = std::move(primaryX);
full_range_ = fullRange;
// d -> 1/d^2 for resolution plots; identity otherwise. Applied to every series alike.
auto transform = [one_over_d2](const std::vector<float> &in, std::vector<float> &out) {
out.resize(in.size());
for (size_t i = 0; i < in.size(); i++) {
if (one_over_d2) {
const float d = in[i];
out[i] = std::isfinite(d) ? 1.0f / (d * d) : 0.0f;
} else
out[i] = in[i];
}
};
transform(input, values);
secondary_ = std::move(secondary);
overlays_ = std::move(overlays);
for (auto &ov: overlays_)
transform(ov.values, ov.values); // in-place
if (dataset != nullptr) {
goniometer_axis = dataset->experiment.GetGoniometer();
image_time_us = dataset->experiment.GetImageTime().count();
} else {
goniometer_axis = {};
image_time_us = {};
}
curr_image = image;
updateChart();
}
void JFJochDatasetInfoChartView::appendSeries(QLineSeries *s, const std::vector<float> &vals,
const std::vector<float> &xs, double &mn, double &mx) const {
// x position for value index i: the mapped image number if available, else the index itself.
auto xpos = [&xs](int64_t i) -> double {
return i < static_cast<int64_t>(xs.size()) ? static_cast<double>(xs[i]) : static_cast<double>(i);
};
if (binning == 1) {
for (int i = 0; i < static_cast<int>(vals.size()); i++) {
const double v = vals[static_cast<size_t>(i)];
if (!std::isfinite(v)) continue;
s->append(xpos(i), v);
mn = std::min(mn, v);
mx = std::max(mx, v);
}
} else {
for (int i = 0; i < static_cast<int>(vals.size() / static_cast<size_t>(binning)); i++) {
double tmp = 0.0;
int64_t count = 0;
for (int b = 0; b < binning; b++) {
const int64_t idx = static_cast<int64_t>(i) * binning + b;
if (idx >= static_cast<int64_t>(vals.size())) break;
const double v = vals[static_cast<size_t>(idx)];
if (std::isfinite(v)) { tmp += v; count++; }
}
if (count > 0) {
const double mean = tmp / static_cast<double>(count);
s->append(xpos(static_cast<int64_t>(i) * binning + binning / 2), mean);
mn = std::min(mn, mean);
mx = std::max(mx, mean);
}
}
}
}
void JFJochDatasetInfoChartView::updateChart() {
// Room for the outermost labels: a line above the highest Y label, and half a label past the last
// X tick on the right. The right margin is in character widths so it holds as the font grows.
chart()->setMargins(QMargins(2, 10, 4 * fontMetrics().averageCharWidth(), 2));
// Important: drop any stale QObject pointers BEFORE rebuilding the chart.
series = nullptr;
currentSeries = nullptr;
chart()->removeAllSeries();
if (m_hoverLine) {
chart()->scene()->removeItem(m_hoverLine);
delete m_hoverLine;
m_hoverLine = nullptr;
}
if (m_hoverLineHorizontal) {
chart()->scene()->removeItem(m_hoverLineHorizontal);
delete m_hoverLineHorizontal;
m_hoverLineHorizontal = nullptr;
}
#ifdef JFJOCH_USE_FFTW
if (m_showFFT)
buildFFTChart();
else
buildTimeDomainChart();
#else
buildTimeDomainChart();
#endif
// Qt Charts elides any axis label that does not fit its allotted box down to "..." (the default
// since 6.2). In a dock this narrow that is every label, so show them in full instead.
for (auto *axis: chart()->axes())
axis->setTruncateLabels(false);
}
void JFJochDatasetInfoChartView::buildTimeDomainChart() {
if (values.size() >= static_cast<size_t>(binning)) {
// At least one full point
series = new QLineSeries(this);
if (!primaryName_.isEmpty())
series->setName(primaryName_);
if (primary_color_.isValid())
series->setColor(primary_color_);
setSeriesPenWidth(series, fontMetrics().height());
currentSeries = new QScatterSeries(this);
currentSeries->setColor(Qt::black); // "current image" marker: fixed, not a run colour
currentSeries->setMarkerSize(9.0);
double dispMin = std::numeric_limits<double>::infinity();
double dispMax = -std::numeric_limits<double>::infinity();
appendSeries(series, values, primary_x_, dispMin, dispMax);
// Overlay runs share the primary's axes and range; build them now so the Y range fits all.
std::vector<QLineSeries *> overlayLines;
overlayLines.reserve(overlays_.size());
for (const auto &ov: overlays_) {
auto *line = new QLineSeries(this);
line->setName(ov.name);
if (ov.color.isValid())
line->setColor(ov.color);
setSeriesPenWidth(line, fontMetrics().height());
appendSeries(line, ov.values, ov.x, dispMin, dispMax);
overlayLines.push_back(line);
}
// ---- current point marker as binned value when binning > 1 ----
if (curr_image >= 0 &&
curr_image < static_cast<int64_t>(values.size())) {
if (binning > 1) {
const int64_t nBins =
static_cast<int64_t>(values.size()) / binning;
if (nBins > 0) {
int64_t binIdx = curr_image / binning;
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
double sum = 0.0;
int64_t count = 0;
for (int64_t b = 0; b < binning; ++b) {
const int64_t idx = binIdx * binning + b;
if (idx >= static_cast<int64_t>(values.size()))
break;
const double v = values[static_cast<size_t>(idx)];
if (std::isfinite(v)) {
sum += v;
++count;
}
}
if (count > 0) {
const double mean =
sum / static_cast<double>(count);
if (std::isfinite(mean)) {
const double centerX =
(static_cast<double>(binIdx) + 0.5) *
static_cast<double>(binning);
currentSeries->append(centerX, mean);
}
}
}
} else if (std::isfinite(values[static_cast<size_t>(curr_image)])) {
currentSeries->append(curr_image,
values[static_cast<size_t>(curr_image)]);
}
}
chart()->addSeries(series);
chart()->addSeries(currentSeries);
chart()->createDefaultAxes();
// ----- X axis handling -----
QValueAxis *axisX = qobject_cast<QValueAxis *>(chart()->axes(Qt::Horizontal, series).value(0));
if (axisX) {
// Always span the whole dataset so a subset run shows at its real position, not stretched.
if (full_range_ > 1)
axisX->setRange(0, static_cast<double>(full_range_ - 1));
if (goniometer_axis.has_value() && m_xUseGoniometerAxis) {
// Hide labels on numeric axis and move it to the top
axisX->setTitleText(QString(""));
axisX->setLabelsVisible(false);
// Re-attach numeric axis on top side (default axis on other side)
chart()->removeAxis(axisX);
chart()->addAxis(axisX, Qt::AlignTop);
series->attachAxis(axisX);
currentSeries->attachAxis(axisX);
// Build a visible category axis on the bottom with goniometer angles
auto *axXcat = new QCategoryAxis();
axXcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
axXcat->setGridLineVisible(false);
axXcat->setMinorGridLineVisible(false);
axXcat->setTitleText(QStringLiteral("Rotation angle (deg.)"));
const int tickCountX = std::max(2, axisX->tickCount());
const double xmin = axisX->min();
const double xmax = axisX->max();
const double xstep = (tickCountX > 1) ? (xmax - xmin) / (tickCountX - 1) : 0.0;
const int64_t lastIdx = static_cast<int64_t>(values.empty() ? 0 : values.size() - 1);
// Ticks snap to round angles - multiples of 90° when the sweep is wide enough,
// finer otherwise - instead of whatever angle the evenly-spaced image ticks land
// on (a 90-450° sweep used to label 359.80). The angle is affine in the image
// index, so each round angle maps back to a fractional index position.
QList<QPair<double, QString>> snapped;
if (lastIdx > 0) {
const double a0 = goniometer_axis->GetAngle_deg(0);
const double a1 = goniometer_axis->GetAngle_deg(lastIdx);
const double lo = std::min(a0, a1), hi = std::max(a0, a1);
double interval = 0.0;
for (double cand : {90.0, 45.0, 30.0, 15.0, 10.0, 5.0, 2.0, 1.0, 0.5, 0.2, 0.1})
if (hi - lo >= 2.0 * cand) { interval = cand; break; }
if (interval > 0.0) {
const double per_image = (a1 - a0) / static_cast<double>(lastIdx);
for (double ang = std::ceil(lo / interval) * interval;
ang <= hi + interval * 1e-6; ang += interval) {
const double xv = (ang - a0) / per_image;
if (xv < xmin || xv > xmax)
continue;
snapped.append({xv, QString::number(ang, 'f', interval < 1.0 ? 1 : 0)});
}
std::sort(snapped.begin(), snapped.end(),
[](const auto &l, const auto &r) { return l.first < r.first; });
}
}
if (snapped.size() >= 2) {
for (const auto &t : snapped)
axXcat->append(t.second, t.first);
} else {
for (int i = 0; i < tickCountX; ++i) {
const double xv = (i == tickCountX - 1) ? xmax : (xmin + i * xstep);
// Map tick position to closest image index
int64_t imgIdx = static_cast<int64_t>(std::llround(xv));
if (imgIdx < 0) imgIdx = 0;
if (imgIdx > lastIdx) imgIdx = lastIdx;
double angleDeg = 0.0;
if (lastIdx >= 0) {
angleDeg = goniometer_axis->GetAngle_deg(imgIdx);
}
QString lab = QString::number(angleDeg, 'f', 2);
axXcat->append(lab, xv);
}
}
chart()->addAxis(axXcat, Qt::AlignBottom);
series->attachAxis(axXcat);
currentSeries->attachAxis(axXcat);
} else {
axisX->setLabelsVisible(true);
axisX->setTitleText(QStringLiteral("Image number"));
}
}
// ----- Y-axis handling -----
QValueAxis *axisY = qobject_cast<QValueAxis *>(chart()->axes(Qt::Vertical, series).value(0));
if (axisY) {
if (std::isfinite(dispMin) && std::isfinite(dispMax)) {
if (m_minYZeroEnabled) {
const double minY = 0.0;
const double maxY = (dispMax > minY) ? dispMax : (minY + 1.0);
axisY->setRange(minY, maxY);
} else {
// Default: tight range to data
if (!(dispMax > dispMin)) {
// Avoid zero-height range
dispMax = dispMin + 1.0;
}
axisY->setRange(dispMin, dispMax);
}
}
if (m_yOneOverD) {
// Keep value axis for numeric range + grid, but move it to the RIGHT
axisY->setLabelsVisible(false);
chart()->removeAxis(axisY);
chart()->addAxis(axisY, Qt::AlignRight);
series->attachAxis(axisY);
currentSeries->attachAxis(axisY);
// Build a mirrored visible axis with labels in d (Å) on the LEFT
auto *axYcat = new QCategoryAxis();
axYcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
axYcat->setGridLineVisible(false);
axYcat->setMinorGridLineVisible(false);
axYcat->setTitleText(QStringLiteral("d (Å)"));
const int tickCountY = std::max(2, axisY->tickCount());
const double ymin = axisY->min();
const double ymax = axisY->max();
const double ystep = (tickCountY > 1) ? (ymax - ymin) / (tickCountY - 1) : 0.0;
for (int i = 0; i < tickCountY; ++i) {
const double yv = (i == tickCountY - 1) ? ymax : (ymin + i * ystep);
QString lab;
if (!(yv > 0.0)) {
lab = QStringLiteral("—"); // invalid for d
} else if (std::abs(yv) < 1e-300) {
lab = QStringLiteral("∞");
} else {
const double d = 1.0 / std::sqrt(yv);
lab = QString("%1 Å").arg(d, 0, 'f', 2);
}
axYcat->append(lab, yv);
}
chart()->addAxis(axYcat, Qt::AlignLeft);
series->attachAxis(axYcat);
currentSeries->attachAxis(axYcat);
// Give a bit more room on the left so labels are not clipped
QMargins m = chart()->margins();
if (m.left() < 12) {
m.setLeft(12);
chart()->setMargins(m);
}
} else {
// Normal numeric labels, axis on the LEFT — unless a secondary quantity takes the
// left side, in which case the primary reads on the RIGHT.
chart()->removeAxis(axisY);
chart()->addAxis(axisY, secondary_.has_value() ? Qt::AlignRight : Qt::AlignLeft);
series->attachAxis(axisY);
currentSeries->attachAxis(axisY);
axisY->setTitleText(QString());
axisY->setLabelsVisible(true);
}
}
// Attach overlay lines to the primary series' final axes; show the legend when overlaying.
const auto finalAxes = series->attachedAxes();
for (auto *line: overlayLines) {
chart()->addSeries(line);
for (auto *ax: finalAxes)
line->attachAxis(ax);
}
// Secondary quantity: same x, its own Y axis on the LEFT (the primary then reads on the
// RIGHT), so a small background value is not flattened by a spot count three orders of
// magnitude larger. Each axis is ranged from its own series alone; on top of that the
// primary's max and the secondary's min are each inflated by that series' own span, so the
// primary (spot count) curve occupies the lower half of the plot and the secondary the
// upper half. That way it is the background axis whose floor gets pushed down - a spot
// count below zero is nonsense, a background below zero is at least conceivable. A
// secondary never combines with the 1/d^2 layout.
if (secondary_.has_value() && !m_yOneOverD) {
auto *line = new QLineSeries(this);
line->setName(secondary_->name);
if (secondary_->color.isValid())
line->setColor(secondary_->color);
setSeriesPenWidth(line, fontMetrics().height());
double secMin = std::numeric_limits<double>::infinity();
double secMax = -std::numeric_limits<double>::infinity();
appendSeries(line, secondary_->values, secondary_->x, secMin, secMax);
chart()->addSeries(line);
auto *axisY2 = new QValueAxis();
if (std::isfinite(secMin) && std::isfinite(secMax)) {
const double lo = m_minYZeroEnabled ? 0.0 : secMin;
const double hi = (secMax > lo) ? secMax : (lo + 1.0);
axisY2->setRange(hi - 2.0 * (hi - lo), hi); // upper half
}
chart()->addAxis(axisY2, Qt::AlignLeft);
line->attachAxis(axisY2);
for (auto *ax: finalAxes)
if (ax->orientation() == Qt::Horizontal)
line->attachAxis(ax);
if (axisY)
axisY->setRange(axisY->min(),
axisY->min() + 2.0 * (axisY->max() - axisY->min())); // lower half
// Each axis carries its series' colour on labels and axis line, so it is clear at a
// glance which axis reads which curve.
if (secondary_->color.isValid()) {
axisY2->setLabelsColor(secondary_->color);
axisY2->setLinePenColor(secondary_->color);
}
if (axisY && primary_color_.isValid()) {
axisY->setLabelsColor(primary_color_);
axisY->setLinePenColor(primary_color_);
}
}
// The current-image marker is not a run - keep it out of the legend.
for (auto *marker: chart()->legend()->markers(currentSeries))
marker->setVisible(false);
// Only overlay runs get a legend. A secondary series is told apart by the colour of its
// axis labels instead: at the height this dock usually has, the room a legend takes is the
// room the axis labels need, and they are what says which line reads on which scale.
chart()->legend()->setVisible(!overlays_.empty());
chart()->legend()->setAlignment(Qt::AlignBottom);
}
}
void JFJochDatasetInfoChartView::setBinning(int64_t val) {
if (val >= 1) {
binning = val;
updateChart();
}
}
void JFJochDatasetInfoChartView::changeEvent(QEvent *event) {
QChartView::changeEvent(event);
if (event->type() == QEvent::FontChange)
updateChart(); // axis room and the curve's pen are both taken from the font
}
void JFJochDatasetInfoChartView::contextMenuEvent(QContextMenuEvent *event) {
QMenu menu(this);
QAction *copyXY = menu.addAction("Copy (x y) points");
copyXY->setEnabled(!values.empty());
QAction *sep1 = menu.addSeparator();
Q_UNUSED(sep1);
QAction *actMinYZero = menu.addAction("Y min at 0");
actMinYZero->setCheckable(true);
actMinYZero->setChecked(m_minYZeroEnabled);
QAction *actXGoniometer = menu.addAction("Use goniometer X-axis");
actXGoniometer->setCheckable(true);
actXGoniometer->setChecked(m_xUseGoniometerAxis);
actXGoniometer->setEnabled(goniometer_axis.has_value());
// Binning sub‑menu (values are defined only once here)
QMenu *binMenu = menu.addMenu("Binning");
const std::array<int, 8> binValues{1, 5, 10, 25, 50, 100, 250, 1000};
QList<QAction *> binActions;
binActions.reserve(static_cast<int>(binValues.size()));
for (int v : binValues) {
QAction *act = binMenu->addAction(QString::number(v));
act->setCheckable(true);
act->setChecked(binning == v);
act->setData(v); // remember which bin this action represents
binActions.push_back(act);
}
#ifdef JFJOCH_USE_FFTW
QAction *actShowFFT = menu.addAction("Show FFT (amplitude vs Hz)");
actShowFFT->setCheckable(true);
actShowFFT->setChecked(m_showFFT);
// Require valid sampling interval
actShowFFT->setEnabled(!values.empty() && image_time_us > 0.0);
#endif
QAction *chosen = menu.exec(event->globalPos());
if (chosen == copyXY) {
QString out;
out.reserve(static_cast<int>(values.size() * 16)); // rough prealloc
for (size_t i = 0; i < values.size(); ++i) {
out.append(QString::number(i));
out.append(' ');
out.append(QString::number(values[i], 'g', 10));
if (i + 1 < values.size()) out.append('\n');
}
QClipboard *cb = QApplication::clipboard();
cb->setText(out);
} else if (chosen == actMinYZero) {
m_minYZeroEnabled = !m_minYZeroEnabled;
updateChart();
} else if (chosen == actXGoniometer) {
m_xUseGoniometerAxis = !m_xUseGoniometerAxis;
updateChart();
} else if (binActions.contains(chosen)) {
// Any binning action selected: read the bin value from QAction::data
bool ok = false;
int v = chosen->data().toInt(&ok);
if (ok && v >= 1) {
setBinning(v);
}
#ifdef JFJOCH_USE_FFTW
} else if (chosen == actShowFFT) {
m_showFFT = !m_showFFT;
updateChart();
#endif
}
}
void JFJochDatasetInfoChartView::mouseMoveEvent(QMouseEvent *event) {
QChartView::mouseMoveEvent(event);
if (!series || values.empty())
return;
#ifdef JFJOCH_USE_FFTW
if (m_showFFT && !m_fftFrequenciesHz.empty()) {
// FFT mode: x is frequency in Hz
const QPointF chartPos = chart()->mapToValue(event->pos(), series);
double f = chartPos.x();
if (!std::isfinite(f))
return;
// If we only have DC, nothing meaningful to show
if (m_fftFrequenciesHz.size() <= 1)
return;
// Find nearest FFT bin, excluding k = 0 (DC component)
int64_t bestIdx = -1;
double bestDiff = std::numeric_limits<double>::infinity();
for (size_t i = 1; i < m_fftFrequenciesHz.size(); ++i) {
const double diff = std::abs(m_fftFrequenciesHz[i] - f);
if (diff < bestDiff) {
bestDiff = diff;
bestIdx = static_cast<int64_t>(i);
}
}
if (bestIdx < 1)
return;
const double fBin = m_fftFrequenciesHz[static_cast<size_t>(bestIdx)];
const double amp = m_fftMagnitudes[static_cast<size_t>(bestIdx)];
// Map the bin's (frequency, amplitude) to scene coords for the crosshair.
const QRectF plotArea = chart()->plotArea();
const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, amp), series);
if (!m_hoverLine) {
m_hoverLine = new QGraphicsLineItem;
m_hoverLine->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLine);
}
if (!m_hoverLineHorizontal) {
m_hoverLineHorizontal = new QGraphicsLineItem;
m_hoverLineHorizontal->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLineHorizontal);
}
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
ptOnChart.x(), plotArea.bottom()));
m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), ptOnChart.y(),
plotArea.right(), ptOnChart.y()));
QString text = QString("f = %1 Hz, amplitude = %2")
.arg(fBin, 0, 'g', 6)
.arg(amp, 0, 'g', 6);
emit writeStatusBar(text, 6000);
// No image loading in FFT mode
m_hoverLoadTimer->stop();
m_hoverPendingIdx = -1;
return;
}
#endif
if (values.empty())
return;
// Map mouse position to chart coordinates
const QPointF chartPos = chart()->mapToValue(event->pos(), series);
const double xVal = chartPos.x();
if (!std::isfinite(xVal) || xVal < 0.0 ||
xVal > static_cast<double>(values.size() - 1)) {
return;
}
int64_t idx = 0;
double yv = std::numeric_limits<double>::quiet_NaN();
if (binning <= 1) {
// Original behavior: nearest frame index and per-image value
idx = std::lround(xVal);
if (idx < 0 || idx >= static_cast<int64_t>(values.size()))
return;
yv = values[static_cast<size_t>(idx)];
} else {
// Binned mode: map x to bin, then use bin mean as the "current point"
const int64_t nBins =
static_cast<int64_t>(values.size()) / binning;
if (nBins <= 0)
return;
int64_t binIdx =
static_cast<int64_t>(std::floor(xVal / static_cast<double>(binning)));
binIdx = std::clamp<int64_t>(binIdx, 0, nBins - 1);
// Representative frame index for status text & image loading
int64_t centerIdx = binIdx * binning + binning / 2;
if (centerIdx >= static_cast<int64_t>(values.size()))
centerIdx = static_cast<int64_t>(values.size()) - 1;
idx = centerIdx;
// Compute bin mean for hover display / "current" value
double sum = 0.0;
int64_t count = 0;
for (int64_t b = 0; b < binning; ++b) {
const int64_t vIdx = binIdx * binning + b;
if (vIdx >= static_cast<int64_t>(values.size()))
break;
const double v = values[static_cast<size_t>(vIdx)];
if (std::isfinite(v)) {
sum += v;
++count;
}
}
if (count > 0)
yv = sum / static_cast<double>(count);
else
yv = std::numeric_limits<double>::quiet_NaN();
}
if (idx < 0 || idx >= static_cast<int64_t>(values.size()))
return;
// Map that x position to scene coords for the vertical line.
// In binned mode this is the bin center index, in unbinned mode the exact frame.
const QRectF plotArea = chart()->plotArea();
const QPointF ptOnChart =
chart()->mapToPosition(QPointF(static_cast<double>(idx), 0.0), series);
if (!m_hoverLine) {
m_hoverLine = new QGraphicsLineItem;
m_hoverLine->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLine);
}
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
ptOnChart.x(), plotArea.bottom()));
// Horizontal crosshair at the hovered value, so a run of hovers reads off as a level (flat) or a
// drift (rising/falling) at a glance, not just point-by-point in the status bar.
if (std::isfinite(yv)) {
const QPointF ptOnChartY =
chart()->mapToPosition(QPointF(static_cast<double>(idx), yv), series);
if (!m_hoverLineHorizontal) {
m_hoverLineHorizontal = new QGraphicsLineItem;
m_hoverLineHorizontal->setPen(QPen(QColor(200, 0, 0, 150), 1.0));
chart()->scene()->addItem(m_hoverLineHorizontal);
}
m_hoverLineHorizontal->setLine(QLineF(plotArea.left(), ptOnChartY.y(),
plotArea.right(), ptOnChartY.y()));
} else if (m_hoverLineHorizontal) {
chart()->scene()->removeItem(m_hoverLineHorizontal);
delete m_hoverLineHorizontal;
m_hoverLineHorizontal = nullptr;
}
// Status bar text based on yv (bin mean in binned mode)
QString text;
if (m_yOneOverD) {
if (std::isfinite(yv) && yv > 0.0) {
const double d = 1.0 / std::sqrt(yv);
text = QString("image = %1 d = %2 Å")
.arg(idx)
.arg(d, 0, 'f', 2);
} else {
text = QString("image = %1, no resolution estimate").arg(idx);
}
} else {
if (std::isfinite(yv)) {
text = QString("image = %1 value = %2")
.arg(idx)
.arg(yv, 0, 'g', 6);
} else {
text = QString("image = %1, no value").arg(idx);
}
}
emit writeStatusBar(text, 6000);
// Debounced image load on hover when Shift is pressed
if (event->modifiers() & Qt::ShiftModifier) {
if (!m_hoverLoadTimer->isActive()) {
m_hoverPendingIdx = -1;
if (idx != curr_image)
emit imageSelected(idx);
m_hoverLoadTimer->start(500); // debounce
} else {
m_hoverPendingIdx = idx;
}
} else {
m_hoverLoadTimer->stop();
m_hoverPendingIdx = -1;
}
}
void JFJochDatasetInfoChartView::leaveEvent(QEvent *event) {
QChartView::leaveEvent(event);
if (m_hoverLine) {
chart()->scene()->removeItem(m_hoverLine);
delete m_hoverLine;
m_hoverLine = nullptr;
}
if (m_hoverLineHorizontal) {
chart()->scene()->removeItem(m_hoverLineHorizontal);
delete m_hoverLineHorizontal;
m_hoverLineHorizontal = nullptr;
}
m_hoverLoadTimer->stop();
m_hoverPendingIdx = -1;
emit writeStatusBar(QString(), 0);
}
void JFJochDatasetInfoChartView::onHoverLoadTimeout() {
if (!(QApplication::keyboardModifiers() & Qt::ShiftModifier))
return;
if (m_hoverPendingIdx >= 0 &&
m_hoverPendingIdx < static_cast<int64_t>(values.size())) {
if (m_hoverPendingIdx != curr_image) {
emit imageSelected(m_hoverPendingIdx);
}
}
}
#ifdef JFJOCH_USE_FFTW
void JFJochDatasetInfoChartView::buildFFTChart() {
const size_t N = values.size();
if (N == 0 || !image_time_us.has_value() || image_time_us <= 0.0) {
return;
}
// Prepare input buffer (single precision, NaN/inf treated as 0)
std::vector<float> in(N, 0.0f);
for (size_t i = 0; i < N; ++i) {
const double v = values[i];
in[i] = std::isfinite(v) ? static_cast<float>(v) : 0.0f;
}
const int n = static_cast<int>(N);
const int nComplex = n / 2 + 1;
std::vector<fftwf_complex> out(static_cast<size_t>(nComplex));
fftwf_plan plan = fftwf_plan_dft_r2c_1d(
n,
in.data(),
out.data(),
FFTW_ESTIMATE);
if (!plan) {
return;
}
fftwf_execute(plan);
fftwf_destroy_plan(plan);
// Compute amplitude spectrum and frequencies (0 .. Nyquist)
m_fftMagnitudes.resize(static_cast<size_t>(nComplex));
m_fftFrequenciesHz.resize(static_cast<size_t>(nComplex));
const double dt = image_time_us.value() * 1e-6; // seconds per sample
const double fs = 1.0 / dt; // sampling frequency
const double df = fs / static_cast<double>(n); // frequency resolution
for (int k = 0; k < nComplex; ++k) {
const double re = out[static_cast<size_t>(k)][0];
const double im = out[static_cast<size_t>(k)][1];
const double mag = std::hypot(re, im); // amplitude
m_fftMagnitudes[static_cast<size_t>(k)] = mag;
m_fftFrequenciesHz[static_cast<size_t>(k)] = static_cast<double>(k) * df;
}
// Build chart series: X = frequency (Hz), Y = amplitude
series = new QLineSeries(this);
currentSeries = nullptr; // no "current image" marker in FFT mode
double magMin = std::numeric_limits<double>::infinity();
double magMax = -std::numeric_limits<double>::infinity();
for (int k = 1; k < nComplex; ++k) {
const double f = m_fftFrequenciesHz[static_cast<size_t>(k)];
const double mag = m_fftMagnitudes[static_cast<size_t>(k)];
series->append(f, mag);
if (mag < magMin) magMin = mag;
if (mag > magMax) magMax = mag;
}
chart()->addSeries(series);
chart()->createDefaultAxes();
QValueAxis *axisX = qobject_cast<QValueAxis *>(chart()->axes(Qt::Horizontal, series).value(0));
QValueAxis *axisY = qobject_cast<QValueAxis *>(chart()->axes(Qt::Vertical, series).value(0));
if (axisX) {
axisX->setTitleText(QStringLiteral("Frequency (Hz)"));
axisX->setLabelsVisible(true);
}
if (axisY) {
if (std::isfinite(magMin) && std::isfinite(magMax)) {
if (!(magMax > magMin)) {
magMax = magMin + 1.0;
}
axisY->setRange(magMin, magMax);
}
axisY->setTitleText(QStringLiteral("Amplitude"));
axisY->setLabelsVisible(true);
}
}
#endif