Files
Jungfraujoch/viewer/charts/JFJochDatasetInfoChartView.cpp
T
leonarski_f 30b6800289
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
v1.0.0-rc.171 (#81)
* 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>
2026-09-17 14:42:52 +02:00

1037 lines
41 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 <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 submenu (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