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
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
274 lines
9.7 KiB
C++
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
|
|
}
|