#include "panels/columns/CategoryAnalysisTab.hpp" #include #include #include #include #include #include #include #include "Theme.hpp" #include "panels/columns/CategorySection.hpp" namespace geopro::app { CategoryAnalysisTab::CategoryAnalysisTab(geopro::data::DatasetFieldDictionary* dict, QWidget* parent) : QWidget(parent) { auto* outer = new QVBoxLayout(this); outer->setContentsMargins(0, 0, 0, 0); outer->setSpacing(0); auto* scroll = new QScrollArea(this); scroll_ = scroll; scroll->setWidgetResizable(true); scroll->setFrameShape(QFrame::NoFrame); scroll->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); // 内容随面板宽自适应,不出横向滚动条 outer->addWidget(scroll, 1); auto* content = new QWidget(scroll); content_ = content; auto* col = new QVBoxLayout(content); col_ = col; col->setContentsMargins(0, space::kSm, 0, space::kSm); // 顶部留白:首段段头不贴顶 col->setSpacing(space::kSm); for (const CategorySpec& spec : categoryConfigs()) { auto* sec = new CategorySection(spec, dict, content); sections_[spec.id] = sec; ordered_.push_back(sec); connect(sec, &CategorySection::collapsedChanged, this, &CategoryAnalysisTab::relayoutSections); // 折叠/展开 → 重排 stretch(向上收) const std::string segId = spec.id; connect(sec, &CategorySection::checkedDatasetsChanged, this, [this, segId](const QStringList& ids) { checkedBySeg_[segId] = ids; recomputeCheckedUnion(); }); connect(sec, &CategorySection::generateVolumeRequested, this, &CategoryAnalysisTab::generateVolumeRequested); connect(sec, &CategorySection::detailRequested, this, &CategoryAnalysisTab::detailRequested); connect(sec, &CategorySection::deleteDatasetRequested, this, &CategoryAnalysisTab::deleteDatasetRequested); connect(sec, &CategorySection::sliceRequested, this, &CategoryAnalysisTab::sliceRequested); connect(sec, &CategorySection::colorScaleRequested, this, &CategoryAnalysisTab::colorScaleRequested); connect(sec, &CategorySection::sliceSaveRequested, this, &CategoryAnalysisTab::sliceSaveRequested); connect(sec, &CategorySection::sliceSaveAsRequested, this, &CategoryAnalysisTab::sliceSaveAsRequested); connect(sec, &CategorySection::sliceExportImageRequested, this, &CategoryAnalysisTab::sliceExportImageRequested); connect(sec, &CategorySection::sliceExportDatRequested, this, &CategoryAnalysisTab::sliceExportDatRequested); connect(sec, &CategorySection::anomalyVisibilityChanged, this, &CategoryAnalysisTab::anomalyVisibilityChanged); connect(sec, &CategorySection::datasetSelected, this, &CategoryAnalysisTab::datasetSelected); // #7:各段等分 stretch → 内容都少时四段平分高度填满面板(初始与 VTK 区等高、不出滚动条); // 某段内容增多时其最小高度(=内容总高)撑大,超出视口则由外层 QScrollArea 统一出纵向滚动条。 col->addWidget(sec, 1); } // 尾部弹簧(末项):默认 0;全部段折叠时由 relayoutSections 置 1,吸收余量把段头顶到顶部。 col->addStretch(0); scroll->setWidget(content); } void CategoryAnalysisTab::relayoutSections() { if (!col_) return; int expanded = 0; for (auto* sec : ordered_) if (sec->isExpanded()) ++expanded; // 展开段 stretch=1(吸收余量、铺满);折叠段 stretch=0(只占段头高,下方不再留空)。 for (auto* sec : ordered_) col_->setStretchFactor(sec, sec->isExpanded() ? 1 : 0); // 尾部弹簧:仅当全部折叠时=1(把所有段头顶到顶部);有任一展开段时=0(由展开段吸收余量)。 col_->setStretch(col_->count() - 1, expanded == 0 ? 1 : 0); } void CategoryAnalysisTab::setBuckets(const CategoryBuckets& b) { const auto& cfg = categoryConfigs(); for (std::size_t i = 0; i < cfg.size() && i < b.segments.size(); ++i) { // voxel(三维体) 段数据来自 mock voxelTree(体/切片/异常),由调用方单独 section("voxel")->setDatasets // 注入;splitByCategory 对它永远是空桶,若在此用空桶覆盖会先清掉其勾选(随后重填但勾选已丢) → // 表现为「创建切片/异常后体/切片选择被清空」(用户 issue 1)。故跳过 voxel,勿覆盖。 if (cfg[i].id == "voxel") continue; if (auto* sec = section(cfg[i].id)) sec->setDatasets(b.segments[i]); } } void CategoryAnalysisTab::setStructure(const std::vector& nodes) { for (auto& [id, sec] : sections_) sec->setStructure(nodes); } void CategoryAnalysisTab::refreshArrayFilters() { for (auto& [id, sec] : sections_) sec->refreshArrayFilter(); } CategorySection* CategoryAnalysisTab::section(const std::string& id) const { const auto it = sections_.find(id); return it != sections_.end() ? it->second : nullptr; } // ds 归属唯一段未知 → 广播到各段,仅含该 ds 的段会命中生效(其余 no-op)。 void CategoryAnalysisTab::setItemChecked(const QString& dsId, bool on) { for (auto* sec : ordered_) sec->setChecked(dsId, on); } void CategoryAnalysisTab::setItemBusy(const QString& dsId, bool busy) { for (auto* sec : ordered_) sec->setBusy(dsId, busy); } void CategoryAnalysisTab::clearAllBusy() { for (auto* sec : ordered_) sec->clearAllBusy(); } void CategoryAnalysisTab::scrollItemToTop(const QString& dsId) { // 先就地展开所在段(同步),再进入多拍重试定位(等布局/滚动条范围结算)。 for (auto* sec : ordered_) if (sec->itemFor(dsId)) { sec->ensureExpanded(); break; } scrollItemToTopRetry(dsId, /*attemptsLeft=*/5); } void CategoryAnalysisTab::scrollItemToTopRetry(const QString& dsId, int attemptsLeft) { if (!scroll_ || !content_) return; CategorySection* sec = nullptr; QTreeWidgetItem* item = nullptr; for (auto* s : ordered_) if ((item = s->itemFor(dsId)) != nullptr) { sec = s; break; } if (sec && item) { sec->ensureExpanded(); for (QTreeWidgetItem* p = item->parent(); p; p = p->parent()) p->setExpanded(true); // 展开树内父节点,使目标行有有效几何 QTreeWidget* tree = sec->listWidget(); tree->scrollToItem(item, QAbstractItemView::PositionAtTop); // 内层树(若有内滚动) // 行顶映射到滚动内容坐标 → 设外层滚动条把该行顶到面板最上方。 const int y = tree->viewport()->mapTo(content_, tree->visualItemRect(item).topLeft()).y(); scroll_->verticalScrollBar()->setValue(y); } // 多拍重试:每拍布局更趋稳定(滚动条 range 长够、行几何更新),末拍稳定到位 → 根治"有时滚不到位"。 if (attemptsLeft > 0) { QPointer self(this); const QString id = dsId; QTimer::singleShot(16, this, [self, id, attemptsLeft]() { if (self) self->scrollItemToTopRetry(id, attemptsLeft - 1); }); } } void CategoryAnalysisTab::recomputeCheckedUnion() { QStringList all; // ds 归属唯一段,跨段不重复,直接拼接 for (const auto& [id, ids] : checkedBySeg_) all += ids; emit checkedDatasetsChanged(all); } } // namespace geopro::app