Files
Jungfraujoch/viewer/widgets/JFJochViewerImageStrip.cpp
T
leonarski_fandClaude Opus 4.8 83d006e79b viewer: "+ Plot" button + strip fits height with in-bitmap numbers
Plots:
- A "+ Plot" button on the dataset-info panel spawns another plot dock, placed
  beside the previous one (horizontal split) so several metrics can be watched
  side by side. Same path as the Charts menu, now one click away.

Image strip:
- The image number is painted into the thumbnail bitmap (coral badge, top-left)
  instead of a text label under the icon, so no height is lost to it.
- Thumbnails are icon-only and scale to the available dock height (resizeEvent),
  so the strip never needs more room than it has; lowered its minimum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:26:15 +02:00

193 lines
7.6 KiB
C++

// SPDX-FileCopyrightText: 2026 Filip Leonarski, Paul Scherrer Institute <filip.leonarski@psi.ch>
// SPDX-License-Identifier: GPL-3.0-only
#include "JFJochViewerImageStrip.h"
#include <QComboBox>
#include <QCheckBox>
#include <QHBoxLayout>
#include <QVBoxLayout>
#include <QScrollArea>
#include <QToolButton>
#include <QLabel>
#include <QPixmap>
#include <QStyle>
#include <QRandomGenerator>
#include <QResizeEvent>
#include <algorithm>
#include <numeric>
#include <cmath>
#include <utility>
JFJochViewerImageStrip::JFJochViewerImageStrip(QWidget *parent) : QWidget(parent) {
auto *layout = new QVBoxLayout(this);
layout->setContentsMargins(4, 2, 4, 2);
auto *controls = new QHBoxLayout();
controls->addWidget(new QLabel("Show", this));
mode_ = new QComboBox(this);
mode_->addItem("Evenly spaced", 0);
mode_->addItem("Most spots", 1);
mode_->addItem("Indexed", 2);
mode_->addItem("Resolution", 3); // representatives across the resolution range
mode_->addItem("Background", 4); // representatives across the background range
controls->addWidget(mode_);
spots_ = new QCheckBox("Spots", this);
spots_->setChecked(true);
spots_->setToolTip("Overlay found spots — a real pattern reads like a constellation");
controls->addWidget(spots_);
auto *refresh = new QToolButton(this);
refresh->setIcon(style()->standardIcon(QStyle::SP_BrowserReload));
refresh->setAutoRaise(true);
refresh->setCursor(Qt::PointingHandCursor);
refresh->setToolTip("Re-roll the representative selection");
controls->addWidget(refresh);
controls->addStretch();
layout->addLayout(controls);
connect(refresh, &QToolButton::clicked, this, [this] { Rebuild(); });
scroll_ = new QScrollArea(this);
scroll_->setWidgetResizable(true);
scroll_->setVerticalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
scroll_->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded);
scroll_->setMinimumHeight(48); // let the dock shrink; thumbnails scale to fit
auto *stripWidget = new QWidget(scroll_);
strip_ = new QHBoxLayout(stripWidget);
strip_->setContentsMargins(0, 0, 0, 0);
strip_->addStretch();
scroll_->setWidget(stripWidget);
layout->addWidget(scroll_);
connect(mode_, &QComboBox::currentIndexChanged, this, [this] { Rebuild(); });
connect(spots_, &QCheckBox::toggled, this, [this] { Rebuild(); });
}
void JFJochViewerImageStrip::datasetLoaded(std::shared_ptr<const JFJochReaderDataset> dataset) {
// Store the latest dataset (metrics may update during live sync / re-analysis) but do NOT
// rebuild here — that would refetch thumbnails on every HTTP image update. Rebuilding happens
// on file open (resetForNewFile, which fires just after this) and on mode / Spots / Refresh.
dataset_ = std::move(dataset);
}
void JFJochViewerImageStrip::resetForNewFile() {
Rebuild(); // a new file/stream opened; dataset_ was just set by datasetLoaded
}
void JFJochViewerImageStrip::thumbnailReady(qint64 image_number, QImage thumb) {
auto it = slots_.find(image_number);
if (it != slots_.end())
it.value()->setIcon(QPixmap::fromImage(thumb));
}
QVector<qint64> JFJochViewerImageStrip::ComputeRepresentatives() const {
QVector<qint64> result;
if (!dataset_)
return result;
const int64_t total = dataset_->experiment.GetImageNum();
if (total <= 0)
return result;
const int N = static_cast<int>(std::min<int64_t>(8, total));
const int mode = mode_->currentIndex();
auto *rng = QRandomGenerator::global();
// Pick one random image from each of N equal bins over an ordered candidate list. Stochastic,
// so Refresh re-rolls a fresh set and deterministic-spacing artefacts are avoided.
auto binnedRandom = [&](const std::vector<int64_t> &candidates) {
const size_t M = candidates.size();
if (M == 0)
return;
const int n = static_cast<int>(std::min<size_t>(N, M));
for (int i = 0; i < n; ++i) {
const size_t lo = static_cast<size_t>(i) * M / n;
size_t hi = static_cast<size_t>(i + 1) * M / n;
if (hi <= lo) hi = lo + 1;
result.push_back(candidates[lo + rng->bounded(static_cast<quint32>(hi - lo))]);
}
};
// Image indices ordered by a metric (finite values only), low to high.
auto orderedByMetric = [](const std::vector<float> &metric) {
std::vector<std::pair<float, int64_t>> vals;
for (size_t i = 0; i < metric.size(); ++i)
if (std::isfinite(metric[i]))
vals.push_back({metric[i], static_cast<int64_t>(i)});
std::sort(vals.begin(), vals.end());
std::vector<int64_t> idx;
idx.reserve(vals.size());
for (const auto &v : vals) idx.push_back(v.second);
return idx;
};
std::vector<int64_t> allIndices(total);
std::iota(allIndices.begin(), allIndices.end(), 0);
const auto &sc = dataset_->spot_count;
const auto &sci = dataset_->spot_count_indexed;
if (mode == 1 && !sc.empty()) { // most spots (deterministic top-N)
std::vector<int64_t> idx(sc.size());
std::iota(idx.begin(), idx.end(), 0);
const size_t n = std::min<size_t>(N, idx.size());
std::partial_sort(idx.begin(), idx.begin() + n, idx.end(),
[&](int64_t a, int64_t b) { return sc[a] > sc[b]; });
idx.resize(n);
std::sort(idx.begin(), idx.end());
for (int64_t i : idx) result.push_back(i);
} else if (mode == 2 && !sci.empty()) { // indexed images, spaced random
std::vector<int64_t> indexed;
for (size_t i = 0; i < sci.size(); ++i)
if (sci[i] > 0) indexed.push_back(static_cast<int64_t>(i));
binnedRandom(indexed.empty() ? allIndices : indexed);
} else if (mode == 3 && !dataset_->resolution_estimate.empty()) {
binnedRandom(orderedByMetric(dataset_->resolution_estimate));
} else if (mode == 4 && !dataset_->bkg_estimate.empty()) {
binnedRandom(orderedByMetric(dataset_->bkg_estimate));
} else { // evenly spaced (random within each bin)
binnedRandom(allIndices);
}
return result;
}
void JFJochViewerImageStrip::Rebuild() {
// Clear the strip (keep the trailing stretch).
slots_.clear();
while (strip_->count() > 1) {
QLayoutItem *item = strip_->takeAt(0);
if (item->widget())
item->widget()->deleteLater();
delete item;
}
if (!dataset_)
return;
const QVector<qint64> reps = ComputeRepresentatives();
int pos = 0;
for (qint64 n : reps) {
auto *btn = new QToolButton(this);
btn->setToolButtonStyle(Qt::ToolButtonIconOnly); // the image number is drawn in the bitmap
btn->setAutoRaise(true);
btn->setCursor(Qt::PointingHandCursor);
btn->setToolTip(QStringLiteral("Open image %1").arg(n + 1));
connect(btn, &QToolButton::clicked, this, [this, n] { emit imageSelected(n, 1); });
strip_->insertWidget(pos++, btn);
slots_[n] = btn;
}
UpdateThumbnailSize();
emit requestThumbnails(reps, spots_->isChecked());
}
void JFJochViewerImageStrip::UpdateThumbnailSize() {
if (!scroll_)
return;
// Scale the thumbnails to the available height so the strip never needs more room than it has.
const int h = std::clamp(scroll_->viewport()->height() - 4, 40, 240);
for (auto *btn : std::as_const(slots_))
btn->setIconSize(QSize(h, h));
}
void JFJochViewerImageStrip::resizeEvent(QResizeEvent *event) {
QWidget::resizeEvent(event);
UpdateThumbnailSize();
}