// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute // SPDX-License-Identifier: GPL-3.0-only #include #include #include #include #include "JFJochChartView.h" JFJochChartView::JFJochChartView(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, &JFJochChartView::onHoverLoadTimeout); } void JFJochChartView::setImage(int64_t val) { if (!currentSeries) return; curr_image = val; if (val < values.size() && val >= 0) { currentSeries->clear(); if (std::isfinite(values[curr_image])) { const double disp = values[curr_image]; if (std::isfinite(disp)) currentSeries->append(curr_image, disp); } } } void JFJochChartView::mousePressEvent(QMouseEvent *event) { if (event->button() == Qt::LeftButton) { QPointF clickedPoint = event->pos(); QPointF chartCoord = chart()->mapToValue(clickedPoint); int64_t val = std::lround(chartCoord.x()); if (val >= 0 && val < values.size()) emit imageSelected(val); } QChartView::mousePressEvent(event); // Call the base implementation } void JFJochChartView::resetZoom() { chart()->zoomReset(); } void JFJochChartView::updateChart() { chart()->removeAllSeries(); if (m_hoverLine) { chart()->scene()->removeItem(m_hoverLine); delete m_hoverLine; m_hoverLine = nullptr; } if (values.size() >= 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 < values.size(); i++) { if (!std::isfinite(values[i])) continue; const double disp = values[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() / binning); i++) { double tmp = 0.0; int64_t count = 0; for (int b = 0; b < binning; b++) { const double v = values[i * binning + b]; 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; } } } } if (curr_image < values.size() && curr_image >= 0 && std::isfinite(values[curr_image])) currentSeries->append(curr_image, values[curr_image]); chart()->addSeries(series); chart()->addSeries(currentSeries); chart()->createDefaultAxes(); // Set Y-axis behavior according to options 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 Y is in 1/d^2, hide numeric labels and add a category axis with d labels if (m_yOneOverD) { axisY->setLabelsVisible(false); // keep grid/ticks from value axis // Build a mirrored visible axis with labels in d (Å) 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 axisY->setTitleText(QString()); axisY->setLabelsVisible(true); } } } } void JFJochChartView::setBinning(int64_t val) { if (val >= 1) { binning = val; updateChart(); } } void JFJochChartView::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 *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(); } } void JFJochChartView::mouseMoveEvent(QMouseEvent *event) { QChartView::mouseMoveEvent(event); if (!series || values.empty()) return; // Map mouse position to chart coordinates const QPointF chartPos = chart()->mapToValue(event->pos(), series); double xVal = chartPos.x(); // Convert x to closest image index int64_t idx = std::lround(xVal); if (idx < 0 || idx >= static_cast(values.size())) return; // Map that x position to scene coords for the vertical line 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 const double yv = values[static_cast(idx)]; 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 { text = QString("image = %1 value = %2") .arg(idx) .arg(yv, 0, 'g', 6); } 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 JFJochChartView::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 JFJochChartView::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); } } }