// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include #include "JFJochDatasetInfoChartView.h" JFJochDatasetInfoChartView::JFJochDatasetInfoChartView(QWidget *parent) : QChartView(new QChart(), parent) { chart()->legend()->hide(); 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::updateChart() { // Important: drop any stale QObject pointers BEFORE rebuilding the chart. series = nullptr; currentSeries = nullptr; chart()->removeAllSeries(); if (m_hoverLine) { chart()->scene()->removeItem(m_hoverLine); delete m_hoverLine; m_hoverLine = nullptr; } #ifdef JFJOCH_USE_FFTW if (m_showFFT) { buildFFTChart(); return; } #endif buildTimeDomainChart(); } void JFJochDatasetInfoChartView::buildTimeDomainChart() { if (values.size() >= static_cast(binning)) { // At least one full point series = new QLineSeries(this); currentSeries = new QScatterSeries(this); double dispMin = std::numeric_limits::infinity(); double dispMax = -std::numeric_limits::infinity(); if (binning == 1) { for (int i = 0; i < static_cast(values.size()); i++) { if (!std::isfinite(values[static_cast(i)])) continue; const double disp = values[static_cast(i)]; if (!std::isfinite(disp)) continue; series->append(i, disp); if (disp < dispMin) dispMin = disp; if (disp > dispMax) dispMax = disp; } } else { for (int i = 0; i < static_cast(values.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(values.size())) break; const double v = values[static_cast(idx)]; if (std::isfinite(v)) { tmp += v; count++; } } if (count > 0) { const double mean = tmp / static_cast(count); const double disp = mean; if (std::isfinite(disp)) { series->append((i + 0.5) * binning, disp); if (disp < dispMin) dispMin = disp; if (disp > dispMax) dispMax = disp; } } } } // ---- 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()->axisX(series)); if (axisX) { if (goniometer_axis.has_value() && m_xUseGoniometerAxis) { // Hide labels on numeric axis and move it to the top axisX->setTitleText(QString("")); axisX->setLabelsVisible(false); // Re-attach numeric axis on top side (default axis on other side) chart()->removeAxis(axisX); chart()->addAxis(axisX, Qt::AlignTop); series->attachAxis(axisX); currentSeries->attachAxis(axisX); // Build a visible category axis on the bottom with goniometer angles auto *axXcat = new QCategoryAxis(); axXcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue); axXcat->setGridLineVisible(false); axXcat->setMinorGridLineVisible(false); axXcat->setTitleText(QStringLiteral("Rotation angle (deg.)")); const int tickCountX = std::max(2, axisX->tickCount()); const double xmin = axisX->min(); const double xmax = axisX->max(); const double xstep = (tickCountX > 1) ? (xmax - xmin) / (tickCountX - 1) : 0.0; const int64_t lastIdx = static_cast(values.empty() ? 0 : values.size() - 1); for (int i = 0; i < tickCountX; ++i) { const double xv = (i == tickCountX - 1) ? xmax : (xmin + i * xstep); // Map tick position to closest image index int64_t imgIdx = static_cast(std::llround(xv)); if (imgIdx < 0) imgIdx = 0; if (imgIdx > lastIdx) imgIdx = lastIdx; double angleDeg = 0.0; if (lastIdx >= 0) { angleDeg = goniometer_axis->GetAngle_deg(imgIdx); } QString lab = QString::number(angleDeg, 'f', 2); axXcat->append(lab, xv); } chart()->addAxis(axXcat, Qt::AlignBottom); series->attachAxis(axXcat); currentSeries->attachAxis(axXcat); } else { axisX->setLabelsVisible(true); axisX->setTitleText(QStringLiteral("Image number")); } } // ----- Y-axis handling ----- QValueAxis *axisY = qobject_cast(chart()->axisY(series)); if (axisY) { if (std::isfinite(dispMin) && std::isfinite(dispMax)) { if (m_minYZeroEnabled) { const double minY = 0.0; const double maxY = (dispMax > minY) ? dispMax : (minY + 1.0); axisY->setRange(minY, maxY); } else { // Default: tight range to data if (!(dispMax > dispMin)) { // Avoid zero-height range dispMax = dispMin + 1.0; } axisY->setRange(dispMin, dispMax); } } if (m_yOneOverD) { // Keep value axis for numeric range + grid, but move it to the RIGHT axisY->setLabelsVisible(false); chart()->removeAxis(axisY); chart()->addAxis(axisY, Qt::AlignRight); series->attachAxis(axisY); currentSeries->attachAxis(axisY); // Build a mirrored visible axis with labels in d (Å) on the LEFT auto *axYcat = new QCategoryAxis(); axYcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue); axYcat->setGridLineVisible(false); axYcat->setMinorGridLineVisible(false); axYcat->setTitleText(QStringLiteral("d (Å)")); const int tickCountY = std::max(2, axisY->tickCount()); const double ymin = axisY->min(); const double ymax = axisY->max(); const double ystep = (tickCountY > 1) ? (ymax - ymin) / (tickCountY - 1) : 0.0; for (int i = 0; i < tickCountY; ++i) { const double yv = (i == tickCountY - 1) ? ymax : (ymin + i * ystep); QString lab; if (!(yv > 0.0)) { lab = QStringLiteral("—"); // invalid for d } else if (std::abs(yv) < 1e-300) { lab = QStringLiteral("∞"); } else { const double d = 1.0 / std::sqrt(yv); lab = QString("%1 Å").arg(d, 0, 'f', 2); } axYcat->append(lab, yv); } chart()->addAxis(axYcat, Qt::AlignLeft); series->attachAxis(axYcat); currentSeries->attachAxis(axYcat); // Give a bit more room on the left so labels are not clipped QMargins m = chart()->margins(); if (m.left() < 12) { m.setLeft(12); chart()->setMargins(m); } } else { // Normal numeric labels, axis on the LEFT chart()->removeAxis(axisY); chart()->addAxis(axisY, Qt::AlignLeft); series->attachAxis(axisY); currentSeries->attachAxis(axisY); axisY->setTitleText(QString()); axisY->setLabelsVisible(true); } } } } void JFJochDatasetInfoChartView::setBinning(int64_t val) { if (val >= 1) { binning = val; updateChart(); } } void JFJochDatasetInfoChartView::contextMenuEvent(QContextMenuEvent *event) { QMenu menu(this); QAction *copyXY = menu.addAction("Copy (x y) points"); copyXY->setEnabled(!values.empty()); QAction *sep1 = menu.addSeparator(); Q_UNUSED(sep1); QAction *actMinYZero = menu.addAction("Y min at 0"); actMinYZero->setCheckable(true); actMinYZero->setChecked(m_minYZeroEnabled); QAction *actXGoniometer = menu.addAction("Use goniometer X-axis"); actXGoniometer->setCheckable(true); actXGoniometer->setChecked(m_xUseGoniometerAxis); actXGoniometer->setEnabled(goniometer_axis.has_value()); // Binning sub‑menu (values are defined only once here) QMenu *binMenu = menu.addMenu("Binning"); const std::array 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 x position of that bin to scene coords for vertical line const QRectF plotArea = chart()->plotArea(); const QPointF ptOnChart = chart()->mapToPosition(QPointF(fBin, 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())); 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())); // 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; } 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()->axisX(series)); QValueAxis *axisY = qobject_cast(chart()->axisY(series)); if (axisX) { axisX->setTitleText(QStringLiteral("Frequency (Hz)")); axisX->setLabelsVisible(true); } if (axisY) { if (std::isfinite(magMin) && std::isfinite(magMax)) { if (!(magMax > magMin)) { magMax = magMin + 1.0; } axisY->setRange(magMin, magMax); } axisY->setTitleText(QStringLiteral("Amplitude")); axisY->setLabelsVisible(true); } } #endif