Build Packages / build:windows:nocuda (push) Successful in 17m20s
Build Packages / build:windows:cuda (push) Successful in 19m52s
Build Packages / build:viewer-tgz:cpu (push) Successful in 9m38s
Build Packages / build:viewer-tgz:cuda (push) Successful in 11m18s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 9m34s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 5m42s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 20m33s
Build Packages / Create release (push) Successful in 33s
Build Packages / build:rugnux:windows (push) Successful in 12m0s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m42s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 14m59s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m8s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 14m35s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 16m55s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 16m58s
Build Packages / Generate python client (push) Successful in 16s
Build Packages / build:rpm (rocky8) (push) Successful in 15m21s
Build Packages / Build documentation (push) Successful in 54s
Build Packages / build:rpm (rocky9) (push) Successful in 16m23s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 12m2s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 10m6s
Build Packages / Unit tests (push) Successful in 1h10m26s
* Rugnux: basic support for CCD images (marCCD, SMV) and for gzipped miniCBF. * `jfjoch_viewer`: opens the CCD formats, and fixes to the dataset plots. * Documentation updates. Reviewed-on: #81 Co-authored-by: Filip Leonarski <filip.leonarski@psi.ch>
1037 lines
41 KiB
C++
1037 lines
41 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 <QGraphicsSimpleTextItem>
|
||
#include <cmath>
|
||
|
||
#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) {
|
||
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() {
|
||
// A minimum height in lines of text, not in pixels, so the plot is not squeezed as the font
|
||
// grows (the compact minimum is set below, from the y-axis title).
|
||
if (compact_label_.isEmpty())
|
||
setMinimumHeight(8 * fontMetrics().height());
|
||
|
||
// 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);
|
||
|
||
if (!compact_label_.isEmpty()) {
|
||
// Half-height plot: minimal margins, the y axis reduced to its two range ends under a
|
||
// short bold name in the series' colour, and no x labels on the upper plot (the lower
|
||
// one carries them for both).
|
||
QFont title_font = font();
|
||
title_font.setBold(true);
|
||
compact_y_label_px_ = 0;
|
||
for (auto *axis: chart()->axes(Qt::Vertical)) {
|
||
if (auto *v = qobject_cast<QValueAxis *>(axis)) {
|
||
v->setTickCount(2);
|
||
v->setLabelFormat("%.3g");
|
||
// Two ticks means the labels are the two range ends: measure them, so a caller can
|
||
// pad this plot out to a taller neighbour's wider labels (see SetCompactYLabelWidth).
|
||
for (double end: {v->min(), v->max()})
|
||
compact_y_label_px_ = std::max(compact_y_label_px_,
|
||
fontMetrics().horizontalAdvance(QString::asprintf("%.3g", end)));
|
||
}
|
||
axis->setTitleVisible(false); // drawn upright by updateCompactTitle() instead
|
||
if (primary_color_.isValid())
|
||
axis->setLabelsColor(primary_color_);
|
||
}
|
||
updateCompactTitle(title_font);
|
||
// The upper plot keeps its x labels but paints them transparent rather than hiding them:
|
||
// Qt then reserves the same label band on both charts, so the two plot areas - the part the
|
||
// curve is drawn in - come out the same height, at any font size.
|
||
if (compact_hide_x_)
|
||
for (auto *axis: chart()->axes(Qt::Horizontal))
|
||
axis->setLabelsColor(Qt::transparent);
|
||
pairCompactYLabels();
|
||
} else if (compact_title_) {
|
||
compact_title_->hide();
|
||
compact_title_w_ = 0;
|
||
}
|
||
}
|
||
|
||
// The name of a half-height plot's y axis. Qt Charts always draws a vertical axis title rotated,
|
||
// which reads awkwardly next to the two numbers it names and is elided to "Sp..." as soon as the
|
||
// font outgrows the plot; paint it upright instead, one letter under the next, in the series'
|
||
// colour. The letters are the tallest thing on the left, so they set the plot's minimum height.
|
||
void JFJochDatasetInfoChartView::updateCompactTitle(const QFont &title_font) {
|
||
if (!compact_title_) {
|
||
compact_title_ = new QGraphicsSimpleTextItem;
|
||
chart()->scene()->addItem(compact_title_);
|
||
connect(chart(), &QChart::plotAreaChanged, this,
|
||
[this](const QRectF &) { placeCompactTitle(); });
|
||
}
|
||
|
||
QString stacked;
|
||
for (const QChar c: compact_label_) {
|
||
if (!stacked.isEmpty())
|
||
stacked += QLatin1Char('\n');
|
||
stacked += c;
|
||
}
|
||
compact_title_->setText(stacked);
|
||
compact_title_->setFont(title_font);
|
||
compact_title_->setBrush(primary_color_.isValid() ? primary_color_
|
||
: palette().color(QPalette::Text));
|
||
compact_title_->show();
|
||
|
||
const QRectF r = compact_title_->boundingRect();
|
||
compact_title_w_ = static_cast<int>(std::ceil(r.width()));
|
||
// Below the plot area sit the x labels and the margins, about two lines of text. The pair is
|
||
// given a common minimum in pairCompactYLabels().
|
||
compact_min_h_ = static_cast<int>(std::ceil(r.height())) + 2 * fontMetrics().height();
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::placeCompactTitle() {
|
||
if (!compact_title_ || !compact_title_->isVisible())
|
||
return;
|
||
// Scene coordinates, like the hover lines: centred on the plot area, in the strip the compact
|
||
// margins keep free for it to the left of the y labels.
|
||
const QRectF plot = chart()->plotArea();
|
||
compact_title_->setPos(2, plot.center().y() - compact_title_->boundingRect().height() / 2);
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::applyCompactMargins() {
|
||
// Qt Charts puts the y labels inside the plot area's left margin, so a plot reading "12345"
|
||
// starts further right than one reading "3". Widen the narrower plot's margin by the
|
||
// difference, which puts both plot areas - and so both curves - at the same left edge.
|
||
const int pad = std::max(0, compact_y_pad_px_ - compact_y_label_px_);
|
||
// 2 px of air on either side of the upright axis name placeCompactTitle() paints in the margin.
|
||
const int title = compact_title_w_ > 0 ? compact_title_w_ + 2 : 0;
|
||
chart()->setMargins(QMargins(2 + title + pad, 4, 4 * fontMetrics().averageCharWidth(), 2));
|
||
placeCompactTitle();
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::setHoverLineY(double y, double snap_to_y) {
|
||
const QRectF plotArea = chart()->plotArea();
|
||
|
||
// Snap to the curve only when the cursor is practically on it, so the line stays a free ruler.
|
||
if (std::isfinite(snap_to_y) && std::abs(snap_to_y - y) < fontMetrics().height() / 2.0)
|
||
y = snap_to_y;
|
||
y = std::clamp(y, plotArea.top(), plotArea.bottom());
|
||
|
||
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(), y, plotArea.right(), y));
|
||
}
|
||
|
||
// Give this plot and its stacked neighbour the same y-label width and the same minimum height.
|
||
// Done on every rebuild, which is what keeps the pair matched after a font change: both are
|
||
// measured again in the new font, and whichever plot handles the font change second re-pairs them.
|
||
void JFJochDatasetInfoChartView::pairCompactYLabels() {
|
||
JFJochDatasetInfoChartView *peer =
|
||
(compact_peer_ && !compact_peer_->compact_label_.isEmpty()) ? compact_peer_.data() : nullptr;
|
||
const int px = peer ? std::max(compact_y_label_px_, peer->compact_y_label_px_) : 0;
|
||
|
||
compact_y_pad_px_ = px;
|
||
applyCompactMargins();
|
||
setMinimumHeight(peer ? std::max(compact_min_h_, peer->compact_min_h_) : compact_min_h_);
|
||
if (peer) {
|
||
peer->compact_y_pad_px_ = px;
|
||
peer->applyCompactMargins();
|
||
peer->setMinimumHeight(minimumHeight()); // equal minimums, or the layout splits unevenly
|
||
}
|
||
}
|
||
|
||
void JFJochDatasetInfoChartView::SetCompact(const QString &axis_label, bool hide_x_labels) {
|
||
compact_label_ = axis_label;
|
||
compact_hide_x_ = hide_x_labels;
|
||
if (compact_label_.isEmpty())
|
||
compact_y_pad_px_ = 0;
|
||
}
|
||
|
||
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. No axis
|
||
// title: Qt Charts caps the horizontal axis area at a fraction of the chart
|
||
// height, and in a dock this short the title is elided to nothing at any window
|
||
// size - the degree sign on every tick carries the unit instead.
|
||
auto *axXcat = new QCategoryAxis();
|
||
axXcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
|
||
axXcat->setGridLineVisible(false);
|
||
axXcat->setMinorGridLineVisible(false);
|
||
|
||
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;
|
||
// Label the whole axis, not just the images that have arrived: the goniometer knows
|
||
// the angle of an image before it is collected, so a live run 10% in gets the ticks
|
||
// of the full sweep instead of a tenth of it labelled ten times as finely.
|
||
const int64_t lastIdx = full_range_ > 1
|
||
? full_range_ - 1
|
||
: 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("%1°").arg(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("%1°").arg(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
|
||
// No "d (Å)" axis title: every label already carries the unit, and no other plot
|
||
// titles its y axis.
|
||
auto *axYcat = new QCategoryAxis();
|
||
axYcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
|
||
axYcat->setGridLineVisible(false);
|
||
axYcat->setMinorGridLineVisible(false);
|
||
|
||
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);
|
||
// Only overlay runs get a legend.
|
||
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);
|
||
}
|
||
m_hoverLine->setLine(QLineF(ptOnChart.x(), plotArea.top(),
|
||
ptOnChart.x(), plotArea.bottom()));
|
||
setHoverLineY(event->pos().y(), 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 cursor, so any level on the plot - not only the curve - can be
|
||
// read off and compared against the rest of the run.
|
||
setHoverLineY(event->pos().y(),
|
||
std::isfinite(yv)
|
||
? chart()->mapToPosition(QPointF(static_cast<double>(idx), yv), series).y()
|
||
: std::numeric_limits<double>::quiet_NaN());
|
||
|
||
// 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
|
||
|