- The chart top margin was too tight (2 px), so the highest Y-axis label overlapped the top edge; give it 10 px of room (both chart views). - The settings dock had no width constraints (unlike the inspector), so it stretched ugly when widened and would not fold back narrowly. Cap it to 330–480 px like the inspector. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
266 lines
9.3 KiB
C++
266 lines
9.3 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) {
|
|
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;
|
|
}
|
|
|
|
// 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
|
|
}
|