viewer: ring labels that stay in view, and an inspector that reads at a glance
Build Packages / Create release (push) Successful in 17s
Build Packages / build:rugnux:aarch64 (cross) (push) Successful in 7m35s
Build Packages / build:rugnux-tgz (x86_64) (push) Successful in 8m55s
Build Packages / build:viewer-tgz:cpu (push) Successful in 10m40s
Build Packages / build:viewer-tgz:cuda (push) Successful in 12m1s
Build Packages / build:rpm (rocky8_nocuda) (push) Successful in 15m35s
Build Packages / build:rpm (rocky9_nocuda) (push) Successful in 16m9s
Build Packages / build:windows:nocuda (push) Successful in 17m10s
Build Packages / build:windows:cuda (push) Successful in 19m39s
Build Packages / build:rpm (ubuntu2204_nocuda) (push) Successful in 14m39s
Build Packages / HDF5 consumer tests (DIALS, XDS) (push) Successful in 25m5s
Build Packages / build:rugnux:windows (push) Successful in 10m49s
Build Packages / build:rpm (ubuntu2404_nocuda) (push) Successful in 19m3s
Build Packages / Generate python client (push) Successful in 44s
Build Packages / Build documentation (push) Successful in 1m15s
Build Packages / build:rpm (rocky8_sls9) (push) Successful in 19m33s
Build Packages / build:rpm (rocky9_sls9) (push) Successful in 19m18s
Build Packages / build:rpm (rocky8) (push) Successful in 17m17s
Build Packages / build:rpm (rocky9) (push) Successful in 18m28s
Build Packages / build:rpm (ubuntu2204) (push) Successful in 21m45s
Build Packages / build:rpm (ubuntu2404) (push) Successful in 19m38s
Build Packages / Unit tests (push) Successful in 1h38m36s

The resolution-ring label was placed at one of four fixed azimuths with
its corner on the ring line, in a hardcoded 16 pt Arial, in the ring's
own magenta: it clipped at the viewport edge, ignored the font size,
and vanished into busy patterns. It now walks the ring's cached trace,
centres on the first point where the whole text fits in the viewport
(failing that, clamps the nearest visible point inside), keeps a
constant on-screen size of 1.2x the application font, and is cased like
the spot markers so it reads on any colour map. In the same function
visibleRect now maps viewport()->rect(), which is what mapToScene
expects.

The dataset plot's angle axis snaps its ticks to round angles -
multiples of 90 degrees when the sweep is wide enough - instead of
whatever angle the evenly-spaced image ticks landed on. The inspector
prints the indexed cell as two aligned rows (alpha below a, beta below
b, gamma below c), mosaicity with two decimals, and "No lattice" in
grey, keeping red for actual trouble. The armed "Analyze image"
button stays navy and is marked by a coral outline instead of turning
into a differently-coloured button. The dataset navigation keys leave
the image's keyPressEvent - the window-wide filter of the previous
commit sees them first.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015vRwzF8PLaaa2novAKYorq
This commit is contained in:
2026-09-13 20:20:54 +02:00
co-authored by Claude Fable 5
parent 702d8f2a1e
commit 53d6aa4ddf
5 changed files with 138 additions and 78 deletions
+6
View File
@@ -6,6 +6,12 @@
* Building Jungfraujoch no longer needs zlib or Eigen installed on the machine: the build downloads and builds both, like every other dependency.
* rugnux reports what became of every frame of a rotation sweep - how many were merged, downgraded and rejected, in frames and in degrees - and what keeping each flagged stretch costs the merged intensities (delta-CC1/2); nothing is excluded on the strength of it.
* The viewer draws its spot markers over a black outline, so they stay visible on a light colour map and are no longer mistaken for a magenta bad pixel or the coral beam stop.
* The viewer has a background (black-point) slider beside the foreground one; hold B and scroll the wheel to adjust it.
* The viewer's keyboard shortcuts - F, B, A, Home/End/PageUp/PageDown - work whichever panel has the focus, not only with the image focused.
* The viewer's resolution-ring labels follow the font size, are cased like the spot markers so they read on any colour map, and stay whole inside the view instead of clipping at its edge.
* The viewer's dataset plots put the rotation-angle ticks on round angles - multiples of 90 degrees when the sweep is wide enough.
* The viewer's inspector prints the indexed cell as two aligned rows, mosaicity with two decimals, and "No lattice" in grey rather than alarm red.
* The viewer's armed "Analyze image" button is marked by a coral outline, instead of turning into a differently-coloured button.
* The viewer has a font size of its own - View -> Font size, or Ctrl+plus and Ctrl+minus - at 100%, 125% or 150% of whatever size the desktop asks for, remembered across restarts.
* The viewer's dataset plots label their axes in full, instead of rendering every label as "...".
* The viewer's dataset plots draw their curves with a heavier line that grows with the font size.
+43 -12
View File
@@ -363,20 +363,51 @@ void JFJochDatasetInfoChartView::buildTimeDomainChart() {
const double xstep = (tickCountX > 1) ? (xmax - xmin) / (tickCountX - 1) : 0.0;
const int64_t lastIdx = static_cast<int64_t>(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<int64_t>(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);
// Ticks snap to round angles - multiples of 90° when the sweep is wide enough,
// finer otherwise - instead of whatever angle the evenly-spaced image ticks land
// on (a 90-450° sweep used to label 359.80). The angle is affine in the image
// index, so each round angle maps back to a fractional index position.
QList<QPair<double, QString>> snapped;
if (lastIdx > 0) {
const double a0 = goniometer_axis->GetAngle_deg(0);
const double a1 = goniometer_axis->GetAngle_deg(lastIdx);
const double lo = std::min(a0, a1), hi = std::max(a0, a1);
double interval = 0.0;
for (double cand : {90.0, 45.0, 30.0, 15.0, 10.0, 5.0, 2.0, 1.0, 0.5, 0.2, 0.1})
if (hi - lo >= 2.0 * cand) { interval = cand; break; }
if (interval > 0.0) {
const double per_image = (a1 - a0) / static_cast<double>(lastIdx);
for (double ang = std::ceil(lo / interval) * interval;
ang <= hi + interval * 1e-6; ang += interval) {
const double xv = (ang - a0) / per_image;
if (xv < xmin || xv > xmax)
continue;
snapped.append({xv, QString::number(ang, 'f', interval < 1.0 ? 1 : 0)});
}
std::sort(snapped.begin(), snapped.end(),
[](const auto &l, const auto &r) { return l.first < r.first; });
}
}
QString lab = QString::number(angleDeg, 'f', 2);
axXcat->append(lab, xv);
if (snapped.size() >= 2) {
for (const auto &t : snapped)
axXcat->append(t.second, t.first);
} else {
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<int64_t>(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);
+63 -51
View File
@@ -247,8 +247,10 @@ void JFJochDiffractionImage::DrawResolutionRings() {
if (ring_mode == RingMode::None)
return;
// Get the visible area in the scene coordinates
QRectF visibleRect = mapToScene(viewport()->geometry()).boundingRect();
// Get the visible area in the scene coordinates (viewport()->rect(), not geometry():
// mapToScene takes viewport coordinates, and geometry() is offset by the viewport's
// position in its parent).
QRectF visibleRect = mapToScene(viewport()->rect()).boundingRect();
int startX = std::max(0, static_cast<int>(std::floor(visibleRect.left())));
int endX = std::min(static_cast<int>(image->Dataset().experiment.GetXPixelsNum()),
@@ -306,7 +308,7 @@ void JFJochDiffractionImage::DrawResolutionRings() {
QVector<qreal> dashPattern = {10, 15};
pen.setDashPattern(dashPattern);
float phi_offset = 0;
int label_stagger = 0;
// Tracing the contours costs 361 geometry evaluations per ring, and they only move when the
// ring list or the geometry changes - not when the view is panned or zoomed, which is when
@@ -360,39 +362,71 @@ void JFJochDiffractionImage::DrawResolutionRings() {
for (const auto &[d, path]: ring_cache_) {
addOverlayItem(scene()->addPath(path, pen));
// Place the "d Å" label at the first cardinal azimuth (staggered per ring) that is visible.
// The "d Å" label follows the application font (View -> Font size) and is white cased in
// black, like the spot markers, so it reads on any colour map and on the ring's own
// colour. The item lives in scene coordinates, so dividing by the view scale keeps it a
// constant size on screen - 1.2x the application font at every zoom. It is centred on the
// first sampled azimuth (staggered per ring) where the whole text fits in the viewport;
// failing that, on the visible ring point closest to the viewport centre, clamped inside -
// never half a label off the edge.
QFont font = this->font();
const qreal f = std::clamp(scale_factor, 0.05, 500.0);
font.setPointSizeF(font.pointSizeF() * 1.2 / f);
auto *textItem = new QGraphicsSimpleTextItem(
QString("%1 Å").arg(QString::number(d, 'f', 2)));
textItem->setFont(font);
textItem->setBrush(Qt::white);
textItem->setPen(QPen(Qt::black, font.pointSizeF() / 8.0));
const QSizeF sz = textItem->boundingRect().size();
QRectF inner = visibleRect.adjusted(sz.height() * 0.3, sz.height() * 0.3,
-sz.height() * 0.3, -sz.height() * 0.3);
if (!inner.isValid())
inner = visibleRect;
bool have_label = false;
QPointF label_pos;
for (float base : {0.0f, static_cast<float>(PI) / 2.0f,
static_cast<float>(PI), 3.0f * static_cast<float>(PI) / 2.0f}) {
try {
auto [x, y] = geom.ResPhiToPxl(d, phi_offset + base);
QPointF p(x, y);
if (std::isfinite(x) && std::isfinite(y) && visibleRect.contains(p)) {
label_pos = p;
have_label = true;
break;
}
} catch (...) {
bool fits = false;
QPointF label_center;
double best_dist = std::numeric_limits<double>::max();
// Walk the ring's own traced points (dense enough that a narrow visible arc is never
// missed), starting at a per-ring stagger so neighbouring rings label different azimuths.
const int n = path.elementCount();
for (int k = 0; k < n; k++) {
const auto e = path.elementAt((k + label_stagger) % n);
const QPointF p(e.x, e.y);
if (!visibleRect.contains(p))
continue;
const QRectF r(p - QPointF(sz.width() / 2, sz.height() / 2), sz);
if (inner.contains(r)) {
label_center = p;
have_label = true;
fits = true;
break;
}
const double dist = QLineF(p, visibleRect.center()).length();
if (dist < best_dist) {
best_dist = dist;
label_center = p;
have_label = true;
}
}
if (have_label) {
QFont font("Arial", 16);
const qreal f = std::clamp(scale_factor, 0.5, 50.0);
font.setPointSizeF(16.0 / sqrt(f)); // base 12pt around scale_factor ~10
auto *textItem = new QGraphicsSimpleTextItem(
QString("%1 Å").arg(QString::number(d, 'f', 2)));
textItem->setFont(font);
textItem->setBrush(ring_color);
textItem->setPos(label_pos);
QRectF r(label_center - QPointF(sz.width() / 2, sz.height() / 2), sz);
if (!fits) {
if (r.left() < inner.left()) r.moveLeft(inner.left());
if (r.right() > inner.right()) r.moveRight(inner.right());
if (r.top() < inner.top()) r.moveTop(inner.top());
if (r.bottom() > inner.bottom()) r.moveBottom(inner.bottom());
}
textItem->setPos(r.topLeft());
scene()->addItem(textItem);
addOverlayItem(textItem);
} else {
delete textItem;
}
phi_offset += 4.0 / 180.0 * PI;
label_stagger += 29;
}
}
@@ -841,30 +875,8 @@ void JFJochDiffractionImage::roiScratchDrawn() {
}
void JFJochDiffractionImage::keyPressEvent(QKeyEvent *event) {
// QGraphicsView would otherwise consume these to scroll the viewport; claim them first so they
// navigate the dataset instead, via the same stepImage() the navigation toolbar already listens
// to. Home/End step by an effectively infinite amount, which stepImage's clamp turns into
// "jump to the first/last image".
switch (event->key()) {
case Qt::Key_Home:
emit stepImage(std::numeric_limits<int>::min());
event->accept();
return;
case Qt::Key_End:
emit stepImage(std::numeric_limits<int>::max());
event->accept();
return;
case Qt::Key_PageUp:
emit stepImage(1);
event->accept();
return;
case Qt::Key_PageDown:
emit stepImage(-1);
event->accept();
return;
default:
break;
}
// Home/End/PageUp/PageDown dataset navigation lives in the main window's application-wide
// event filter, so it works with any panel focused - this handler never sees those keys.
if (event->key() == Qt::Key_Delete && image && !selected_roi_.isEmpty()) {
ROIDefinition rois = image->Dataset().experiment.ROI().GetROIDefinition();
+21 -12
View File
@@ -6,14 +6,23 @@
#include <QFormLayout>
#include <QRegularExpression>
static QString mkUnitCell(const UnitCell &uc) {
return QString("<b>%1</b> Å <b>%2</b> Å <b>%3</b> Å <b>%4</b>° <b>%5</b>° <b>%6</b>°")
.arg(QString::number(uc.a, 'f', 1))
.arg(QString::number(uc.b, 'f', 1))
.arg(QString::number(uc.c, 'f', 1))
.arg(QString::number(uc.alpha, 'f', 1))
.arg(QString::number(uc.beta, 'f', 1))
.arg(QString::number(uc.gamma, 'f', 1));
// Two aligned rows - the lengths over their angles, so that alpha sits below a, beta below b,
// gamma below c. Qt's rich text does not inherit a surrounding colour into a table, so the
// colour is applied per cell.
static QString mkUnitCell(const UnitCell &uc, const char *color = nullptr) {
const QString style = color ? QString(" style=\"color: %1;\"").arg(color) : QString();
auto num = [&style](double v) {
return QString("<td align=\"right\"%1><b>%2</b>&nbsp;&nbsp;</td>")
.arg(style).arg(QString::number(v, 'f', 1));
};
auto unit = [&style](const char *u) {
return QString("<td%1>%2</td>").arg(style).arg(u);
};
return "<table cellspacing=\"0\" cellpadding=\"0\"><tr>"
+ num(uc.a) + num(uc.b) + num(uc.c) + unit("Å")
+ "</tr><tr>"
+ num(uc.alpha) + num(uc.beta) + num(uc.gamma) + unit("°")
+ "</tr></table>";
}
static QString mkSourceInstrumentText(const DiffractionExperiment& exp) {
@@ -315,7 +324,7 @@ void JFJochViewerImageStatistics::loadImage(std::shared_ptr<const JFJochReaderIm
auto pr = image->ImageData().profile_radius;
auto mos = image->ImageData().mosaicity_deg;
if (mos && std::isfinite(mos.value())) {
text = QString("<b>%1</b>°").arg(QString::number(mos.value(), 'f', 6));
text = QString("<b>%1</b>°").arg(QString::number(mos.value(), 'f', 2));
profile_radius_label->setText("Mosaicity:");
profile_radius->setText(text);
if (pr && std::isfinite(pr.value())) {
@@ -366,8 +375,7 @@ void JFJochViewerImageStatistics::loadImage(std::shared_ptr<const JFJochReaderIm
auto latt = image->ImageData().indexing_lattice;
if (latt) {
text = QString("<span style=\"color: purple;\">%1</span>")
.arg(mkUnitCell(latt->GetUnitCell()));
text = mkUnitCell(latt->GetUnitCell(), "purple");
auto vec0 = latt->Vec0();
auto vec1 = latt->Vec1();
@@ -395,7 +403,8 @@ void JFJochViewerImageStatistics::loadImage(std::shared_ptr<const JFJochReaderIm
.arg(QString::fromStdString(image->Dataset().experiment.GetSpaceGroupName()));
}
} else
text = QString("<span style=\"color: red;\"><b>No lattice</b></span>");
// Not indexed is a normal state, not an error - grey, so red keeps meaning trouble.
text = QString("<span style=\"color: gray;\">No lattice</span>");
indexed->setToolTip(tooltip);
indexed->setText(text);
+5 -3
View File
@@ -78,10 +78,12 @@ JFJochViewerSettingsDock::JFJochViewerSettingsDock(const SpotFindingSettings &sp
// The two analysis actions sit on top of the panel. "Analyze image" is a toggle (re-analyse the
// current frame now and on every change while armed); "Analyze dataset" launches a processing job
// whose kind (MX vs azimuthal) is decided by the MX/AzInt toggle below — no separate switch.
// The armed (checked) state stays navy like its sibling - a full coral fill read as a
// different kind of button - and is marked by a coral outline instead.
const QString heroStyle =
"QPushButton { background-color:#1F3A5F; color:white; border:none; border-radius:3px;"
" padding:5px 10px; } QPushButton:hover { background-color:#16314f; }"
" QPushButton:checked { background-color:#FA7268; } QPushButton:disabled { background-color:#9aa6b3; }";
"QPushButton { background-color:#1F3A5F; color:white; border:2px solid transparent;"
" border-radius:3px; padding:3px 8px; } QPushButton:hover { background-color:#16314f; }"
" QPushButton:checked { border-color:#FA7268; } QPushButton:disabled { background-color:#9aa6b3; }";
auto *analyzeImageBtn = new QPushButton(FramesIcon(1), " Analyze image", this);
analyzeImageBtn->setCheckable(true);
analyzeImageBtn->setStyleSheet(heroStyle);