* Dataset-info plot hover shows a horizontal crosshair alongside the existing vertical one, so a run of hovers reads as a level or a drift at a glance. * `A` applies auto-contrast once instead of switching on persistent Auto mode (a new `oneShotAutoForeground()`, distinct from the toolbar's Auto toggle); pressing it a second time on the same image switches Auto on for good, and pressing it while Auto is already on leaves it on. The value itself comes from one `AutoForegroundValue()` shared with the continuous Auto path. * `Home`/`End`/`Page Up`/`Page Down` navigate the dataset (first/last image, one image forward/back); claimed in the diffraction view's keyPressEvent before QGraphicsView's default handling, which otherwise eats them to scroll the viewport. * Alt+wheel dataset navigation is removed - some window managers already took it before the app ever saw it, per the caveat that used to sit next to its entry in the shortcuts list. * The image strip dock and its dedicated thumbnail-rendering machinery in the reading worker (SetThumbnail*, RenderThumbnail_i) are removed; it cost a lot and nothing else used any of it. * Toolbar text: "Colour" -> "Color". * Color map, Auto and HDR mode now persist across sessions, the same way the window layout already does. * The Inspector's "Dataset:" path wraps at '/' (a zero-width space after each one) instead of being cut off when it doesn't fit one line. * LoadFile no longer reopens an already-open file - it forwards straight to LoadImage instead - which was the likely cause of a D-Bus client's per-image navigation lagging behind the same navigation done via the grid-scan hover. * New file-manager dock (left, tabbed with Settings via tabifyDockWidget): a directory tree filtered to *_master.h5/*_process.h5, rooted at JUNGFRAUJOCH_DATA_ROOT if set, else the last-browsed root, else the home directory. Most beamline users care about opening images, not reprocessing settings, so it's the tab raised by default. Its filter box narrows files only - a directory always passes - because filtering the directories too hid the matching files under any directory whose own name did not match, and hid the root's ancestors, which left the tree with no valid root index until the root was set again by hand. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PogHY7bWXV4bpctDPdyuDN Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
855 lines
31 KiB
C++
855 lines
31 KiB
C++
// 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"
|
||
|
||
JFJochDatasetInfoChartView::JFJochDatasetInfoChartView(QWidget *parent)
|
||
: QChartView(new QChart(), parent) {
|
||
chart()->legend()->hide();
|
||
// Reclaim Qt Charts' outer layout padding and trim the inner margins so the axis labels keep
|
||
// their room even when the dock is short (otherwise they are the first thing Qt drops).
|
||
chart()->layout()->setContentsMargins(0, 0, 0, 0);
|
||
chart()->setMargins(QMargins(2, 10, 8, 2)); // top room so the highest Y label is not clipped
|
||
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) {
|
||
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);
|
||
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() {
|
||
// 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();
|
||
return;
|
||
}
|
||
#endif
|
||
|
||
buildTimeDomainChart();
|
||
}
|
||
|
||
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_);
|
||
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);
|
||
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()->axisX(series));
|
||
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);
|
||
|
||
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()->axisY(series));
|
||
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
|
||
chart()->removeAxis(axisY);
|
||
chart()->addAxis(axisY, 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);
|
||
}
|
||
// The current-image marker is not a run - keep it out of the legend.
|
||
for (auto *marker: chart()->legend()->markers(currentSeries))
|
||
marker->setVisible(false);
|
||
chart()->legend()->setVisible(!overlays_.empty());
|
||
chart()->legend()->setAlignment(Qt::AlignBottom);
|
||
}
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::setBinning(int64_t val) {
|
||
if (val >= 1) {
|
||
binning = val;
|
||
updateChart();
|
||
}
|
||
}
|
||
|
||
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()->axisX(series));
|
||
QValueAxis *axisY = qobject_cast<QValueAxis *>(chart()->axisY(series));
|
||
|
||
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
|
||
|