// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include #include #include #include #include #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(values.size())) return; // For binning > 1, show the binned mean at bin center if (binning > 1) { const int64_t nBins = static_cast(values.size()) / binning; if (nBins <= 0) return; int64_t binIdx = val / binning; binIdx = std::clamp(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(values.size())) break; const double v = values[static_cast(idx)]; if (std::isfinite(v)) { sum += v; ++count; } } if (count > 0) { const double mean = sum / static_cast(count); if (std::isfinite(mean)) { const double centerX = (static_cast(binIdx) + 0.5) * static_cast(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(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(values.size()) / binning; if (nBins <= 0) { QChartView::mousePressEvent(event); return; } int64_t binIdx = static_cast(std::floor(xVal / static_cast(binning))); binIdx = std::clamp(binIdx, 0, nBins - 1); int64_t centerIdx = binIdx * binning + binning / 2; if (centerIdx >= static_cast(values.size())) centerIdx = static_cast(values.size()) - 1; selectedIdx = centerIdx; } if (selectedIdx >= 0 && selectedIdx < static_cast(values.size())) { emit imageSelected(selectedIdx); } } QChartView::mousePressEvent(event); // Call the base implementation } void JFJochDatasetInfoChartView::resetZoom() { chart()->zoomReset(); } void JFJochDatasetInfoChartView::loadValues(const std::vector &input, int64_t image, bool one_over_d2, const JFJochReaderDataset *dataset, const QString &primaryName, std::vector overlays, const QColor &primaryColor, std::vector 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 &in, std::vector &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 &vals, const std::vector &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(xs.size()) ? static_cast(xs[i]) : static_cast(i); }; if (binning == 1) { for (int i = 0; i < static_cast(vals.size()); i++) { const double v = vals[static_cast(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(vals.size() / static_cast(binning)); i++) { double tmp = 0.0; int64_t count = 0; for (int b = 0; b < binning; b++) { const int64_t idx = static_cast(i) * binning + b; if (idx >= static_cast(vals.size())) break; const double v = vals[static_cast(idx)]; if (std::isfinite(v)) { tmp += v; count++; } } if (count > 0) { const double mean = tmp / static_cast(count); s->append(xpos(static_cast(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(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(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(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(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::infinity(); double dispMax = -std::numeric_limits::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 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(values.size())) { if (binning > 1) { const int64_t nBins = static_cast(values.size()) / binning; if (nBins > 0) { int64_t binIdx = curr_image / binning; binIdx = std::clamp(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(values.size())) break; const double v = values[static_cast(idx)]; if (std::isfinite(v)) { sum += v; ++count; } } if (count > 0) { const double mean = sum / static_cast(count); if (std::isfinite(mean)) { const double centerX = (static_cast(binIdx) + 0.5) * static_cast(binning); currentSeries->append(centerX, mean); } } } } else if (std::isfinite(values[static_cast(curr_image)])) { currentSeries->append(curr_image, values[static_cast(curr_image)]); } } chart()->addSeries(series); chart()->addSeries(currentSeries); chart()->createDefaultAxes(); // ----- X axis handling ----- QValueAxis *axisX = qobject_cast(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(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(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> 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(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(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(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 binValues{1, 5, 10, 25, 50, 100, 250, 1000}; QList binActions; binActions.reserve(static_cast(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(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::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(i); } } if (bestIdx < 1) return; const double fBin = m_fftFrequenciesHz[static_cast(bestIdx)]; const double amp = m_fftMagnitudes[static_cast(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(values.size() - 1)) { return; } int64_t idx = 0; double yv = std::numeric_limits::quiet_NaN(); if (binning <= 1) { // Original behavior: nearest frame index and per-image value idx = std::lround(xVal); if (idx < 0 || idx >= static_cast(values.size())) return; yv = values[static_cast(idx)]; } else { // Binned mode: map x to bin, then use bin mean as the "current point" const int64_t nBins = static_cast(values.size()) / binning; if (nBins <= 0) return; int64_t binIdx = static_cast(std::floor(xVal / static_cast(binning))); binIdx = std::clamp(binIdx, 0, nBins - 1); // Representative frame index for status text & image loading int64_t centerIdx = binIdx * binning + binning / 2; if (centerIdx >= static_cast(values.size())) centerIdx = static_cast(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(values.size())) break; const double v = values[static_cast(vIdx)]; if (std::isfinite(v)) { sum += v; ++count; } } if (count > 0) yv = sum / static_cast(count); else yv = std::numeric_limits::quiet_NaN(); } if (idx < 0 || idx >= static_cast(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(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(idx), yv), series).y() : std::numeric_limits::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(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 in(N, 0.0f); for (size_t i = 0; i < N; ++i) { const double v = values[i]; in[i] = std::isfinite(v) ? static_cast(v) : 0.0f; } const int n = static_cast(N); const int nComplex = n / 2 + 1; std::vector out(static_cast(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(nComplex)); m_fftFrequenciesHz.resize(static_cast(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(n); // frequency resolution for (int k = 0; k < nComplex; ++k) { const double re = out[static_cast(k)][0]; const double im = out[static_cast(k)][1]; const double mag = std::hypot(re, im); // amplitude m_fftMagnitudes[static_cast(k)] = mag; m_fftFrequenciesHz[static_cast(k)] = static_cast(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::infinity(); double magMax = -std::numeric_limits::infinity(); for (int k = 1; k < nComplex; ++k) { const double f = m_fftFrequenciesHz[static_cast(k)]; const double mag = m_fftMagnitudes[static_cast(k)]; series->append(f, mag); if (mag < magMin) magMin = mag; if (mag > magMax) magMax = mag; } chart()->addSeries(series); chart()->createDefaultAxes(); QValueAxis *axisX = qobject_cast(chart()->axes(Qt::Horizontal, series).value(0)); QValueAxis *axisY = qobject_cast(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