Files
Jungfraujoch/viewer/charts/JFJochSimpleChartView.cpp
T
leonarski_fandClaude Opus 5 4bb3d44983
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 9m11s
Build Packages / build:windows:nocuda (push) Successful in 16m58s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 19m38s
Build Packages / build:windows:cuda (push) Successful in 19m52s
Build Packages / build:viewer-tgz:cpu (push) Successful in 21m15s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 22m55s
Build Packages / build:viewer-tgz:cuda (push) Successful in 23m26s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 27m44s
Build Packages / build:rugnux:windows (push) Successful in 11m1s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 28m7s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m49s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 23m23s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 26m16s
Build Packages / build:rpm (rocky9) (push) Successful in 23m21s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 22m45s
Build Packages / Generate python client (push) Successful in 29s
Build Packages / build:rpm (rocky8) (push) Successful in 28m58s
Build Packages / Create release (push) Skipped
Build Packages / Build documentation (push) Successful in 1m34s
Build Packages / DIALS test (push) Successful in 26m3s
Build Packages / XDS test (durin plugin) (push) Successful in 10m42s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 28m4s
Build Packages / XDS test (neggia plugin) (push) Successful in 9m59s
Build Packages / XDS test (JFJoch plugin) (push) Successful in 11m1s
Build Packages / Unit tests (push) Successful in 2h8m52s
Build Packages / Unit tests (pull_request) Successful in 1h26m12s
Build Packages / build:windows:nocuda (pull_request) Successful in 16m5s
Build Packages / build:viewer-tgz:cpu (pull_request) Successful in 15m31s
Build Packages / build:viewer-tgz:cuda (pull_request) Successful in 17m5s
Build Packages / build:rugnux-tgz (x86_64) (pull_request) Successful in 15m7s
Build Packages / build:rugnux:windows (pull_request) Successful in 9m47s
Build Packages / build:rugnux:aarch64 (cross) (pull_request) Successful in 9m26s
Build Packages / build:rpm (rocky8_nocuda) (pull_request) Successful in 20m18s
Build Packages / build:rpm (rocky9_nocuda) (pull_request) Successful in 18m34s
Build Packages / build:rpm (ubuntu2204_nocuda) (pull_request) Successful in 24m53s
Build Packages / build:rpm (ubuntu2404_nocuda) (pull_request) Successful in 17m31s
Build Packages / build:rpm (rocky8_sls9) (pull_request) Successful in 26m29s
Build Packages / build:rpm (rocky9_sls9) (pull_request) Successful in 22m26s
Build Packages / build:rpm (rocky8) (pull_request) Successful in 25m4s
Build Packages / build:rpm (rocky9) (pull_request) Successful in 21m42s
Build Packages / build:rpm (ubuntu2204) (pull_request) Successful in 23m24s
Build Packages / build:rpm (ubuntu2404) (pull_request) Successful in 19m14s
Build Packages / DIALS test (pull_request) Successful in 18m12s
Build Packages / XDS test (durin plugin) (pull_request) Successful in 11m23s
Build Packages / XDS test (JFJoch plugin) (pull_request) Successful in 11m9s
Build Packages / XDS test (neggia plugin) (pull_request) Successful in 9m51s
Build Packages / Generate python client (pull_request) Successful in 33s
Build Packages / Build documentation (pull_request) Successful in 42s
Build Packages / Create release (pull_request) Skipped
Build Packages / build:windows:cuda (pull_request) Successful in 14m44s
viewer: label the merge plot over the range it is drawn on
The merge-statistics window asked for an absolute y-axis - CC1/2 and CCref on
0..100, everything else from 0 - by setting the range on the chart's value axis
after JFJochSimpleChartView::UpdateData had already built the chart. The visible
tick labels are a separate QCategoryAxis whose entries UpdateData had generated
from the range of the data, and those entries were not rebuilt. So the plot was
drawn over 0..100 while the labels down its left edge covered only 91.5..100 and
crowded into the top tenth; the numbers matching the drawn range appeared only
on the right-hand grid axis, which the same call had made visible. CC1/2 showed
it worst because its range is the narrowest, but every metric was affected.

Give UpdateData the range instead, so the ticks and both axes come from one
number. The side-panel azimuthal-integration chart passes no range and renders
pixel-identically.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EFEJG6WBQv8th4UJFNe53N
2026-09-02 09:23:02 +02:00

274 lines
9.7 KiB
C++

// SPDX-FileCopyrightText: 2025 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochSimpleChartView.h"
#include <QMenu>
#include <QClipboard>
#include <QApplication>
#include <QCategoryAxis>
#include <QRegularExpression>
#include <QGraphicsLayout>
JFJochSimpleChartView::JFJochSimpleChartView(QWidget *parent)
: QChartView(new QChart(), parent) {
chart()->legend()->hide();
chart()->layout()->setContentsMargins(0, 0, 0, 0); // reclaim padding for the plot + labels
chart()->setMargins(QMargins(2, 10, 8, 2)); // top room so the highest Y label is not clipped
setMinimumHeight(140); // soft floor: enough for the axis labels to stay readable
setRenderHint(QPainter::Antialiasing);
setMouseTracking(true);
//setRubberBand(QChartView::RubberBand::HorizontalRubberBand);
}
void JFJochSimpleChartView::UpdateData(const std::vector<float> &in_x, const std::vector<float> &in_y,
QString legend_x, QString legend_y, bool in_one_over_d,
std::optional<std::pair<double, double>> y_range) {
one_over_d = in_one_over_d;
x = in_x;
y = in_y;
// Remove hover line if any
if (m_hoverLine) {
chart()->scene()->removeItem(m_hoverLine);
delete m_hoverLine;
m_hoverLine = nullptr;
}
m_series = nullptr;
chart()->removeAllSeries();
// Remove all axes to avoid duplicates
for (auto ax: chart()->axes()) chart()->removeAxis(ax);
if (x.empty() || x.size() != y.size()) return;
auto *series = new QLineSeries(this);
for (size_t i = 0; i < x.size(); ++i) {
if (std::isfinite(y[i]))
series->append(x[i], y[i]);
}
chart()->addSeries(series);
m_series = series;
// Compute Y range
double ymin = std::numeric_limits<double>::max();
double ymax = std::numeric_limits<double>::lowest();
bool found_finite = false;
for (float val : y) {
if (std::isfinite(val)) {
ymin = std::min(ymin, static_cast<double>(val));
ymax = std::max(ymax, static_cast<double>(val));
found_finite = true;
}
}
if (!found_finite) {
ymin = 0.0;
ymax = 1.0;
} else if (ymin == ymax) {
const double eps = (std::abs(ymax) > 0.0) ? std::abs(ymax) * 1e-6 : 1.0;
ymin -= eps;
ymax += eps;
}
// A range asked for by the caller replaces the one taken from the data. Everything below - both
// axes and the tick labels - is built from ymin/ymax, so the drawn range stays the labelled one.
if (y_range && y_range->second > y_range->first) {
ymin = y_range->first;
ymax = y_range->second;
}
// Hidden value axis (left): range + grid
auto *axYvalue = new QValueAxis();
axYvalue->setTitleText(legend_y);
axYvalue->setRange(ymin, ymax);
axYvalue->setTickCount(5); // ensure ticks exist now (avoid 0 before layout)
axYvalue->setLabelsVisible(false); // hide numeric labels (grid only)
chart()->addAxis(axYvalue, Qt::AlignRight);
series->attachAxis(axYvalue);
// SI formatter (Plotly/D3-like ".3~s")
int precision = 3;
const auto formatSI = [precision](double v) -> QString {
if (v == 0.0 || std::isnan(v)) return QStringLiteral("0");
static const struct { int exp; const char* pre; } si[] = {
{24,"Y"},{21,"Z"},{18,"E"},{15,"P"},{12,"T"},{9,"G"},{6,"M"},{3,"k"},
{0,""},{-3,"m"},{-6,"µ"},{-9,"n"},{-12,"p"},{-15,"f"},{-18,"a"},{-21,"z"},{-24,"y"}
};
double av = std::fabs(v);
int chosenExp = 0; const char* chosenPre = "";
if (av > 0.0) {
int exp10 = static_cast<int>(std::floor(std::log10(av)));
int exp3 = (exp10 >= 0) ? (exp10 / 3) * 3 : -(((-exp10 + 2) / 3) * 3);
if (exp3 > 24) exp3 = 24;
if (exp3 < -24) exp3 = -24;
for (auto &e : si) if (e.exp == exp3) { chosenExp = e.exp; chosenPre = e.pre; break; }
}
const double scaled = v / std::pow(10.0, chosenExp);
QString num = QString::number(scaled, 'g', std::max(1, precision)); // ~ trimming effect
return (chosenPre && *chosenPre) ? num + QStringLiteral(" ") + QString::fromUtf8(chosenPre) : num;
};
// Visible category axis (right) with formatted labels
auto *axYcat = new QCategoryAxis();
axYcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
axYcat->setGridLineVisible(false);
axYcat->setMinorGridLineVisible(false);
axYcat->setTitleText(legend_y);
axYcat->setLabelsVisible(true); // make sure labels are actually shown
// Build ticks from hidden axis tick count immediately (stable even before layout)
const int tickCountY = std::max(2, axYvalue->tickCount());
const double ystep = (ymax - ymin) / (tickCountY - 1);
for (int i = 0; i < tickCountY; ++i) {
const double yv = (i == tickCountY - 1) ? ymax : (ymin + i * ystep);
axYcat->append(formatSI(yv), yv);
}
chart()->addAxis(axYcat, Qt::AlignLeft);
series->attachAxis(axYcat);
// Give a bit more room on the right so labels are not clipped
QMargins m = chart()->margins();
if (m.right() < 12) {
m.setRight(12);
chart()->setMargins(m);
}
// Build X range
const float xmin = x[0], xmax = x[x.size() - 1];
if (one_over_d) {
auto *axXTop = new QValueAxis();
axXTop->setRange(xmin, xmax);
axXTop->setTickCount(5);
axXTop->setLabelsVisible(false);
chart()->addAxis(axXTop, Qt::AlignTop);
series->attachAxis(axXTop);
auto *axXcat = new QCategoryAxis();
axXcat->setLabelsPosition(QCategoryAxis::AxisLabelsPositionOnValue);
axXcat->setGridLineVisible(false);
axXcat->setMinorGridLineVisible(false);
axXcat->setTitleText(legend_x);
const int tickCount = axXTop->tickCount();
const double step = (tickCount > 1) ? (xmax - xmin) / (tickCount - 1) : 0.0;
for (int i = 0; i < tickCount; ++i) {
const double xv = xmin + i * step;
const QString lab = (std::abs(xv) < 1e-12)
? QStringLiteral("∞")
: QString::number(1.0 / sqrtf(xv), 'f', 2);
axXcat->append(lab, xv);
}
chart()->addAxis(axXcat, Qt::AlignBottom);
series->attachAxis(axXcat);
} else {
auto *axX = new QValueAxis();
axX->setRange(xmin, xmax);
axX->setTitleText(legend_x);
chart()->addAxis(axX, Qt::AlignBottom);
series->attachAxis(axX);
}
}
void JFJochSimpleChartView::ClearData() {
x.clear();
y.clear();
m_series = nullptr;
if (m_hoverLine) {
chart()->scene()->removeItem(m_hoverLine);
delete m_hoverLine;
m_hoverLine = nullptr;
}
chart()->removeAllSeries();
}
void JFJochSimpleChartView::contextMenuEvent(QContextMenuEvent *event) {
QMenu menu(this);
QAction *copyXY = menu.addAction("Copy (x y) points");
copyXY->setEnabled(!x.empty() && x.size() == y.size());
QAction *chosen = menu.exec(event->globalPos());
if (chosen == copyXY) {
QString out;
out.reserve(static_cast<int>(x.size() * 16)); // rough prealloc
for (size_t i = 0; i < x.size() && i < y.size(); ++i) {
out.append(QString::number(x[i], 'g', 10));
out.append(' ');
out.append(QString::number(y[i], 'g', 10));
if (i + 1 < x.size()) out.append('\n');
}
QClipboard *cb = QApplication::clipboard();
cb->setText(out);
}
}
void JFJochSimpleChartView::mouseMoveEvent(QMouseEvent *event) {
QChartView::mouseMoveEvent(event);
if (!m_series || x.empty() || x.size() != y.size())
return;
// Map mouse position to chart coordinates
const QPointF chartPos = chart()->mapToValue(event->pos(), m_series);
const double xVal = chartPos.x();
// Find closest index in x[]
auto it = std::lower_bound(x.begin(), x.end(), static_cast<float>(xVal));
if (it == x.end() && !x.empty())
it = std::prev(x.end());
if (it == x.end())
return;
size_t idx = static_cast<size_t>(std::distance(x.begin(), it));
if (idx > 0) {
const float xPrev = x[idx - 1];
if (std::abs(xPrev - xVal) < std::abs(x[idx] - xVal))
--idx;
}
const float xNearest = x[idx];
// Map that data point to scene coords to get the x position
// Use 0.0 for Y to ensure mapToPosition works even if yNearest is NaN
const QPointF ptOnSeries = chart()->mapToPosition(QPointF(xNearest, 0.0), m_series);
const QRectF plotArea = chart()->plotArea();
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(ptOnSeries.x(), plotArea.top(),
ptOnSeries.x(), plotArea.bottom()));
// Send to status bar
emit writeStatusBar(getText(idx), 6000);
}
QString JFJochSimpleChartView::getText(size_t idx) {
if (idx > x.size())
return {};
if (one_over_d)
return QString("d = %1 Å, y = %2")
.arg(1 / sqrt(x[idx]), 0, 'g', 4)
.arg(y[idx], 0, 'g', 6);
else
return QString("x = %1, y = %2")
.arg(x[idx], 0, 'g', 6)
.arg(y[idx], 0, 'g', 6);
}
void JFJochSimpleChartView::leaveEvent(QEvent *event) {
QChartView::leaveEvent(event);
if (m_hoverLine) {
chart()->scene()->removeItem(m_hoverLine);
delete m_hoverLine;
m_hoverLine = nullptr;
}
emit writeStatusBar(QString(), 6000); // clear status bar when leaving
}