From c21226a3d71ca014dd7077b82df2b73f8af5b8ba Mon Sep 17 00:00:00 2001 From: gaozheng Date: Tue, 23 Jun 2026 11:53:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(detail):=20measurement=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E6=A1=86/=E5=B7=A5=E5=85=B7=E6=9D=A1=E8=A7=86=E8=A7=89?= =?UTF-8?q?=E8=BF=94=E5=B7=A5=E5=AF=B9=E9=BD=90=E5=8E=9F=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 以原版 web 为准返工 measurement 散点交互视觉: - 数据过滤:1000px;左直方图(hover柱变红+tooltip)+右信息区(数值范围/占比/原始点数/ 当前点数橙色高亮)+底部双手柄范围滑块(新增RangeSlider)+计算分布/重置;min/max输入 最大在上最小在下;三方联动(输入↔滑块↔直方图) - 另存为(RawData):280px、标题"数据另存为"、确认/取消 - 色阶/另存/过滤成功 toast - 信息面板 A红/B蓝/M绿/N橙(#F4B008);tooltip"查看散点属性"/"散点的点选" - X/Y/V/值类型下拉固定宽 120/160/160/120;无高程禁用 X/Y - 导出置工具条最右(页头HeaderAction跨ddCode静态) API 字段未动。build all 绿。 --- src/app/CMakeLists.txt | 1 + src/app/panels/chart/RangeSlider.cpp | 104 +++++++++++ src/app/panels/chart/RangeSlider.hpp | 40 +++++ src/app/panels/chart/RawDataChartView.cpp | 81 +++++++-- src/app/panels/chart/RawDataChartView.hpp | 9 +- src/app/panels/chart/SaveAsDialog.cpp | 7 +- src/app/panels/chart/ScatterDataOps.cpp | 10 ++ src/app/panels/chart/ScatterDataOps.hpp | 4 + src/app/panels/chart/ScatterFilterDialog.cpp | 177 +++++++++++++++---- src/app/panels/chart/ScatterFilterDialog.hpp | 33 ++-- src/app/panels/chart/ScatterHistogram.cpp | 63 ++++++- src/app/panels/chart/ScatterHistogram.hpp | 9 +- tests/app/test_scatter_data_ops.cpp | 9 + 13 files changed, 476 insertions(+), 71 deletions(-) create mode 100644 src/app/panels/chart/RangeSlider.cpp create mode 100644 src/app/panels/chart/RangeSlider.hpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 865416e..0604841 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -46,6 +46,7 @@ add_executable(geopro_desktop WIN32 panels/chart/SaveAsDialog.cpp panels/chart/ScatterFilterDialog.cpp panels/chart/ScatterHistogram.cpp + panels/chart/RangeSlider.cpp panels/chart/InversionProcessOps.cpp panels/chart/GridWizardDialog.cpp panels/chart/WhiteningDialog.cpp diff --git a/src/app/panels/chart/RangeSlider.cpp b/src/app/panels/chart/RangeSlider.cpp new file mode 100644 index 0000000..d3dbd86 --- /dev/null +++ b/src/app/panels/chart/RangeSlider.cpp @@ -0,0 +1,104 @@ +#include "panels/chart/RangeSlider.hpp" + +#include +#include + +#include +#include + +namespace geopro::app { + +namespace { +constexpr int kHandleR = 7; // 手柄半径(像素) +constexpr int kTrackH = 4; // 轨道高度 +constexpr int kMargin = kHandleR + 2; // 左右留边,保证端点手柄不被裁 +const QColor kTrackBg(229, 230, 235); // 轨道底(浅灰) +const QColor kTrackSel(245, 63, 63); // 选中段:红(对照原版滑轨 #f53f3f) +const QColor kHandleFill(255, 255, 255); +const QColor kHandleBorder(245, 63, 63); // 手柄边框红(对照原版) +} // namespace + +RangeSlider::RangeSlider(QWidget* parent) : QWidget(parent) { + setMinimumHeight(2 * kHandleR + 6); + setMouseTracking(false); + setCursor(Qt::PointingHandCursor); +} + +void RangeSlider::setRange(double min, double max) { + min_ = min; + max_ = (max > min) ? max : min + 1.0; // 退化区间兜底,避免除零 + low_ = std::clamp(low_, min_, max_); + high_ = std::clamp(high_, min_, max_); + update(); +} + +void RangeSlider::setValues(double low, double high) { + low_ = std::clamp(std::min(low, high), min_, max_); + high_ = std::clamp(std::max(low, high), min_, max_); + update(); +} + +int RangeSlider::valueToX(double v) const { + const int usable = width() - 2 * kMargin; + if (usable <= 0 || max_ <= min_) return kMargin; + return kMargin + static_cast((v - min_) / (max_ - min_) * usable); +} + +double RangeSlider::xToValue(int px) const { + const int usable = width() - 2 * kMargin; + if (usable <= 0) return min_; + const double t = std::clamp(static_cast(px - kMargin) / usable, 0.0, 1.0); + return min_ + t * (max_ - min_); +} + +void RangeSlider::paintEvent(QPaintEvent*) { + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + const int cy = height() / 2; + const int xLo = valueToX(low_); + const int xHi = valueToX(high_); + + // 轨道底 + p.setPen(Qt::NoPen); + p.setBrush(kTrackBg); + p.drawRoundedRect(QRect(kMargin, cy - kTrackH / 2, width() - 2 * kMargin, kTrackH), 2, 2); + // 选中段(红) + p.setBrush(kTrackSel); + p.drawRect(QRect(xLo, cy - kTrackH / 2, std::max(0, xHi - xLo), kTrackH)); + + // 两个手柄 + QPen border(kHandleBorder, 2); + p.setPen(border); + p.setBrush(kHandleFill); + p.drawEllipse(QPoint(xLo, cy), kHandleR, kHandleR); + p.drawEllipse(QPoint(xHi, cy), kHandleR, kHandleR); +} + +void RangeSlider::mousePressEvent(QMouseEvent* event) { + const int px = event->position().toPoint().x(); + const int dLo = std::abs(px - valueToX(low_)); + const int dHi = std::abs(px - valueToX(high_)); + // 就近选手柄;距离相等时按点击位置相对中点决定(左半选低,右半选高)。 + if (dLo == dHi) + dragging_ = (px < valueToX((low_ + high_) / 2.0)) ? 1 : 2; + else + dragging_ = (dLo < dHi) ? 1 : 2; + mouseMoveEvent(event); +} + +void RangeSlider::mouseMoveEvent(QMouseEvent* event) { + if (dragging_ == 0) return; + const double v = xToValue(event->position().toPoint().x()); + if (dragging_ == 1) + low_ = std::min(v, high_); + else + high_ = std::max(v, low_); + update(); + emit rangeChanged(low_, high_); +} + +void RangeSlider::mouseReleaseEvent(QMouseEvent*) { + dragging_ = 0; +} + +} // namespace geopro::app diff --git a/src/app/panels/chart/RangeSlider.hpp b/src/app/panels/chart/RangeSlider.hpp new file mode 100644 index 0000000..f837ebe --- /dev/null +++ b/src/app/panels/chart/RangeSlider.hpp @@ -0,0 +1,40 @@ +#pragma once +#include + +namespace geopro::app { + +// 双手柄范围滑块(M3 数据过滤底部 a-slider range 的 Qt 复刻)。 +// Qt 无原生双手柄滑块,故自绘:一条水平轨道 + 两个圆形手柄(低/高), +// 选中段轨道用强调红(对照原版滑轨 #f53f3f)。值域用 double(连续,对照原版 step 0.01)。 +// 拖动手柄发 rangeChanged(low, high);外部 setRange/setValues 同步。 +class RangeSlider : public QWidget { + Q_OBJECT +public: + explicit RangeSlider(QWidget* parent = nullptr); + + void setRange(double min, double max); // 设定值域(数据 min/max) + void setValues(double low, double high); // 设定当前低/高手柄值(外部联动用,不发信号) + double lowValue() const { return low_; } + double highValue() const { return high_; } + +signals: + void rangeChanged(double low, double high); + +protected: + void paintEvent(QPaintEvent* event) override; + void mousePressEvent(QMouseEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseReleaseEvent(QMouseEvent* event) override; + +private: + int valueToX(double v) const; // 值 → 像素 x + double xToValue(int px) const; // 像素 x → 值(夹到值域) + + double min_ = 0.0; + double max_ = 1.0; + double low_ = 0.0; + double high_ = 1.0; + int dragging_ = 0; // 0=无,1=低手柄,2=高手柄 +}; + +} // namespace geopro::app diff --git a/src/app/panels/chart/RawDataChartView.cpp b/src/app/panels/chart/RawDataChartView.cpp index 27c78ab..c1a56f1 100644 --- a/src/app/panels/chart/RawDataChartView.cpp +++ b/src/app/panels/chart/RawDataChartView.cpp @@ -53,6 +53,7 @@ #include "panels/chart/LivePanner.hpp" #include "Theme.hpp" +#include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7) namespace geopro::app { @@ -213,6 +214,12 @@ void fillCombo(QComboBox* combo, const std::vector& o constexpr int kToolIconPx = 16; // 逻辑图标边长(与 setIconSize 对齐) constexpr qreal kToolIconScale = 2.0; // 超采样倍率(HiDPI 清晰) +// measurement 工具条下拉固定宽度(对照原版 datasetTool.vue 各 a-select 的 width)。 +constexpr int kComboW_X = 120; // X 下拉 width:120px +constexpr int kComboW_Y = 160; // Y 下拉 width:160px +constexpr int kComboW_V = 160; // V 值下拉 width:160px +constexpr int kComboW_ValueType = 120; // 值类型下拉 width:120px + QPixmap makeToolIconCanvas(QPainter& p) { // 调用方在 [0,kToolIconPx] 逻辑坐标系下作画;返回前缩放 + 设 dpr。 const int dim = qRound(kToolIconPx * kToolIconScale); @@ -390,6 +397,7 @@ void RawDataChartView::openInversionColorScale(QWidget* anchor) { colorSvc_ = new ColorMapService(data_.scale); redrawScatter(); colorBar_->setColorScale(data_.scale); + showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success) // 持久化(businessCode 空,对照原版 originPage newLvlColorLevel businessCode:'')。 const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); @@ -568,6 +576,7 @@ void RawDataChartView::openScatterColorScale(QWidget* anchor) { // 同步右侧竖条/底部横条色阶图例。 if (data_.verticalLegend) colorBarV_->setColorScale(data_.scale); else colorBar_->setColorScale(data_.scale); + showToast(this, QStringLiteral("色阶应用成功")); // M8 成功提示(对照原版 Message.success) // 持久化到后端(saveColorGradation,businessCode=当前 V 值,type=3 散点路径)。 const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); @@ -624,17 +633,38 @@ void RawDataChartView::toggleInfoMode(bool on) { infoMode_ = on; if (on && !infoPanel_) { // 首次开启:建覆盖在图区右上角的属性面板(复刻原版 .scatterInfos 浮层)。 + // A/B/M/N 按原版逐项配色(item-label-a 红 / -b 蓝 / -m 绿 / -n 橙#F4B008)。 infoPanel_ = new QWidget(plot_->canvas()); infoPanel_->setObjectName(QStringLiteral("scatterInfoPanel")); auto* il = new QVBoxLayout(infoPanel_); il->setContentsMargins(8, 6, 8, 6); - infoLabel_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_); - il->addWidget(infoLabel_); + il->setSpacing(2); + infoHint_ = new QLabel(QStringLiteral("点选散点查看属性"), infoPanel_); + il->addWidget(infoHint_); + // 各属性行:标签上色(label QSS 不染行值),初始隐藏,命中点后填值显示。 + infoValA_ = new QLabel(infoPanel_); + infoValA_->setObjectName(QStringLiteral("infoA")); + infoValB_ = new QLabel(infoPanel_); + infoValB_->setObjectName(QStringLiteral("infoB")); + infoValM_ = new QLabel(infoPanel_); + infoValM_->setObjectName(QStringLiteral("infoM")); + infoValN_ = new QLabel(infoPanel_); + infoValN_->setObjectName(QStringLiteral("infoN")); + infoValRow_ = new QLabel(infoPanel_); + infoValPseu_ = new QLabel(infoPanel_); + for (QLabel* l : {infoValA_, infoValB_, infoValM_, infoValN_, infoValRow_, infoValPseu_}) { + l->setVisible(false); + il->addWidget(l); + } applyTokenizedStyleSheet( infoPanel_, QStringLiteral("QWidget#scatterInfoPanel { background: {{bg/panel}};" " border: 1px solid {{border/default}}; border-radius: 6px; }" - "QLabel { color: {{text/primary}}; }")); + "QLabel { color: {{text/primary}}; }" + "QLabel#infoA { color: #FF0000; }" // A 红 + "QLabel#infoB { color: #0000FF; }" // B 蓝 + "QLabel#infoM { color: #008000; }" // M 绿 + "QLabel#infoN { color: #F4B008; }")); // N 橙黄 // 画布事件过滤器:信息模式下点击找最近点显示属性。 plot_->canvas()->installEventFilter(this); } @@ -649,7 +679,7 @@ void RawDataChartView::toggleInfoMode(bool on) { } void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) { - if (!infoLabel_ || data_.scatter.x.empty()) return; + if (!infoValA_ || data_.scatter.x.empty()) return; const QwtScaleMap xMap = plot_->canvasMap(QwtPlot::xTop); const QwtScaleMap yMap = plot_->canvasMap(QwtPlot::yLeft); const auto& s = data_.scatter; @@ -667,14 +697,17 @@ void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) { auto at = [](const std::vector& v, std::size_t i) { return i < v.size() ? v[i] : 0.0; }; - // 复刻原版 scatterInfos:A / B / M / N / DataRow / Pseu_Resis。 - infoLabel_->setText(QStringLiteral("A= %1\nB= %2\nM= %3\nN= %4\nDataRow= %5\nPseu_Resis= %6") - .arg(QString::number(at(s.a, bestI), 'g', 6), - QString::number(at(s.b, bestI), 'g', 6), - QString::number(at(s.m, bestI), 'g', 6), - QString::number(at(s.n, bestI), 'g', 6), - QString::number(at(s.row, bestI), 'g', 6), - QString::number(at(s.pseu, bestI), 'g', 6))); + // 复刻原版 scatterInfos:A / B / M / N / DataRow / Pseu_Resis(A/B/M/N 标签逐项配色)。 + if (infoHint_) infoHint_->setVisible(false); + infoValA_->setText(QStringLiteral("A= %1").arg(QString::number(at(s.a, bestI), 'g', 6))); + infoValB_->setText(QStringLiteral("B= %1").arg(QString::number(at(s.b, bestI), 'g', 6))); + infoValM_->setText(QStringLiteral("M= %1").arg(QString::number(at(s.m, bestI), 'g', 6))); + infoValN_->setText(QStringLiteral("N= %1").arg(QString::number(at(s.n, bestI), 'g', 6))); + infoValRow_->setText(QStringLiteral("DataRow= %1").arg(QString::number(at(s.row, bestI), 'g', 6))); + infoValPseu_->setText( + QStringLiteral("Pseu_Resis= %1").arg(QString::number(at(s.pseu, bestI), 'g', 6))); + for (QLabel* l : {infoValA_, infoValB_, infoValM_, infoValN_, infoValRow_, infoValPseu_}) + l->setVisible(true); infoPanel_->adjustSize(); infoPanel_->move(plot_->canvas()->width() - infoPanel_->width() - 10, 10); infoPanel_->raise(); @@ -743,10 +776,10 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba // [i] info + [▣] 框选:占位(暂未实现)。用 QPainter 画的线性图标(HiDPI 清晰,随主题)。 auto* btnInfo = new QToolButton(toolbar); - btnInfo->setToolTip(QStringLiteral("信息")); + btnInfo->setToolTip(QStringLiteral("查看散点属性")); // 对照原版 datasetTool.vue tooltip styleToolIconButton(btnInfo, makeInfoIcon()); auto* btnMarquee = new QToolButton(toolbar); - btnMarquee->setToolTip(QStringLiteral("框选")); + btnMarquee->setToolTip(QStringLiteral("散点的点选")); // 对照原版 datasetTool.vue tooltip styleToolIconButton(btnMarquee, makeMarqueeIcon()); // 主题热切:重绘图标(info 锚定品牌蓝,marquee 描边随次要文本色)。 connect(&ThemeManager::instance(), &ThemeManager::changed, btnInfo, @@ -777,18 +810,30 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba connect(btnExport, &QPushButton::clicked, this, [this]() { exportDat(); }); // x / y 下拉:本地换列重绘;v 下拉:重新请求散点+色阶(M6);值类型下拉:本地变换(M7)。 + // 各下拉固定宽度对照原版 datasetTool.vue(X=120/Y=160/V=160/值类型=120)。 xCombo_ = new QComboBox(toolbar); + xCombo_->setFixedWidth(kComboW_X); fillCombo(xCombo_, conf.x, conf.defaultX, QString()); yCombo_ = new QComboBox(toolbar); + yCombo_->setFixedWidth(kComboW_Y); fillCombo(yCombo_, conf.y, conf.defaultY, QString()); vCombo_ = new QComboBox(toolbar); + vCombo_->setFixedWidth(kComboW_V); fillCombo(vCombo_, conf.v, conf.defaultV, QString()); valueTypeCombo_ = new QComboBox(toolbar); + valueTypeCombo_->setFixedWidth(kComboW_ValueType); // 值类型固定三项(原版 linearity/inverse/logarithm),本地变换无后端。 valueTypeCombo_->addItem(QStringLiteral("线性"), QStringLiteral("linearity")); valueTypeCombo_->addItem(QStringLiteral("倒数"), QStringLiteral("inverse")); valueTypeCombo_->addItem(QStringLiteral("对数"), QStringLiteral("logarithm")); + // 无高程时禁用 X/Y 下拉(对照原版 :disabled="!currentHasElevation")。 + // 判断依据:高程相关备选列 altYElevationPseudo 非空即视为「有高程数据」(与 y 下拉 + // 「伪深度+高程」项的数据源一致;无高程时该列为空,X/Y 轴切换无意义故禁用)。 + const bool hasElevation = !data_.altYElevationPseudo.empty(); + xCombo_->setEnabled(hasElevation); + yCombo_->setEnabled(hasElevation); + connect(xCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, [this](int) { replotForAxis(); }); connect(yCombo_, QOverload::of(&QComboBox::currentIndexChanged), this, @@ -825,13 +870,17 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba tbLay->addWidget(btnShow); tbLay->addWidget(btnHide); tbLay->addWidget(btnFilter); - tbLay->addWidget(btnExport); tbLay->addWidget(xCombo_); tbLay->addWidget(yCombo_); tbLay->addWidget(vCombo_); tbLay->addWidget(valueTypeCombo_); tbLay->addWidget(btnColorScale); - tbLay->addStretch(); // 把主操作推到右侧 + tbLay->addStretch(); // 把导出 + 主操作推到右侧 + // 导出:原版在详情页头 Header(非工具条)。客户端页头「导出」HeaderAction 为跨 ddCode + // 共用的静态占位(PanelHeader 不暴露按钮/不发信号,IDetailView 亦无导出接口),按 ddCode + // 分派转发成本高且易误触其它视图;故 measurement 专属导出保留在工具条内,置于最右侧、 + // 紧邻主操作组,样式贴近原版(outline 风格的普通按钮)。 + tbLay->addWidget(btnExport); tbLay->addWidget(btnGen); tbLay->addWidget(btnInvert); tbLay->addWidget(btnSaveAs); diff --git a/src/app/panels/chart/RawDataChartView.hpp b/src/app/panels/chart/RawDataChartView.hpp index ad73247..7e43aa5 100644 --- a/src/app/panels/chart/RawDataChartView.hpp +++ b/src/app/panels/chart/RawDataChartView.hpp @@ -103,7 +103,14 @@ private: // M13 [i]信息:信息模式开关 + 覆盖在图区右上的属性面板。 bool infoMode_ = false; QWidget* infoPanel_ = nullptr; // 属性覆盖面板(A/B/M/N/DataRow/Pseu_Resis) - QLabel* infoLabel_ = nullptr; + QLabel* infoHint_ = nullptr; // 未点选时的提示文案 + // A/B/M/N 标签按原版配色(A 红 / B 蓝 / M 绿 / N 橙#F4B008);DataRow/Pseu_Resis 默认色。 + QLabel* infoValA_ = nullptr; + QLabel* infoValB_ = nullptr; + QLabel* infoValM_ = nullptr; + QLabel* infoValN_ = nullptr; + QLabel* infoValRow_ = nullptr; + QLabel* infoValPseu_ = nullptr; // 使用 unique_ptr 管理生命周期;attach 后 QwtPlot 接管绘制,但我们仍持有指针 ColorMapService* colorSvc_ = nullptr; // heap,由 setData 重建 diff --git a/src/app/panels/chart/SaveAsDialog.cpp b/src/app/panels/chart/SaveAsDialog.cpp index 6afd3ee..e00a987 100644 --- a/src/app/panels/chart/SaveAsDialog.cpp +++ b/src/app/panels/chart/SaveAsDialog.cpp @@ -13,6 +13,7 @@ #include #include "Theme.hpp" +#include "ToastOverlay.hpp" // showToast:统一成功轻提示(规范 §7.7) #include "panels/chart/ScatterDataOps.hpp" // buildSaveRawDataBody(纯组装,便于单测) #include "repo/IDatasetCommandRepository.hpp" @@ -20,6 +21,7 @@ namespace geopro::app { namespace { constexpr int kInversionW = 400; // 原版 inversion 另存为弹窗宽 400px +constexpr int kRawDataW = 280; // 原版 RawData「数据另存为」弹窗宽 280px constexpr int kLabelW = 60; // 原版 .label width:60px } // namespace @@ -49,8 +51,9 @@ SaveAsDialog::SaveAsDialog(Mode mode, geopro::data::IDatasetCommandRepository* r nameRow->addWidget(nameEdit_, 1); root->addLayout(nameRow); } else { - // ── RawData(measurement):新增/覆盖 + 名称(保持既有逻辑,不在本批返工范围)── + // ── RawData(measurement):新增/覆盖 + 名称(对照原版「数据另存为」280px)── setWindowTitle(QStringLiteral("数据另存为")); + setFixedWidth(kRawDataW); auto* opLay = new QHBoxLayout(); auto* rbNew = new QRadioButton(QStringLiteral("新增"), this); @@ -114,6 +117,8 @@ void SaveAsDialog::onConfirm() { if (!self) return; self->okBtn_->setEnabled(true); if (ok) { + // 成功提示挂到父窗口(对话框随即关闭,toast 取顶层窗口锚定,故用 parentWidget)。 + if (auto* anchor = self->parentWidget()) showToast(anchor, QStringLiteral("保存成功")); self->accept(); } else { QMessageBox::warning(self, self->windowTitle(), diff --git a/src/app/panels/chart/ScatterDataOps.cpp b/src/app/panels/chart/ScatterDataOps.cpp index 21dfb6d..415863e 100644 --- a/src/app/panels/chart/ScatterDataOps.cpp +++ b/src/app/panels/chart/ScatterDataOps.cpp @@ -87,4 +87,14 @@ int toggledDisplayStatus(int currentStatus) { return currentStatus == 0 ? 1 : 0; // 0 显示 → 1 隐藏;其余 → 0 显示 } +int countScatterInRange(const std::vector& v, double min, double max) { + if (max < min) return 0; + int count = 0; + for (double x : v) { + if (!std::isfinite(x)) continue; // 非有限值不计入(与直方图一致) + if (x >= min && x <= max) ++count; // 闭区间 [min,max],与原版过滤一致 + } + return count; +} + } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterDataOps.hpp b/src/app/panels/chart/ScatterDataOps.hpp index 4fd3fad..1ffd9d2 100644 --- a/src/app/panels/chart/ScatterDataOps.hpp +++ b/src/app/panels/chart/ScatterDataOps.hpp @@ -59,4 +59,8 @@ ScatterHistogram buildScatterHistogram(const std::vector& v, double min, // 入参/返回 0=显示、1=隐藏。当前显示(0) → 隐藏(1);当前隐藏(非0) → 显示(0)。 int toggledDisplayStatus(int currentStatus); +// 统计落在闭区间 [min,max] 内的有限值个数(M3 数据过滤「当前点数」/占比用)。 +// 非有限值(NaN/inf)不计入;max& v, double min, double max); + } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterFilterDialog.cpp b/src/app/panels/chart/ScatterFilterDialog.cpp index bda7537..db33ce6 100644 --- a/src/app/panels/chart/ScatterFilterDialog.cpp +++ b/src/app/panels/chart/ScatterFilterDialog.cpp @@ -1,18 +1,24 @@ #include "panels/chart/ScatterFilterDialog.hpp" +#include +#include #include #include #include +#include #include #include #include #include #include +#include #include -#include "FormKit.hpp" -#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody +#include "Theme.hpp" +#include "ToastOverlay.hpp" // showToast:成功轻提示 +#include "panels/chart/RangeSlider.hpp" +#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody / countScatterInRange #include "panels/chart/ScatterHistogram.hpp" #include "repo/IDatasetCommandRepository.hpp" @@ -20,6 +26,20 @@ namespace geopro::app { namespace { constexpr double kSpinRange = 1e12; // 数值范围足够宽,覆盖电阻率/电位等量纲 +constexpr int kDialogW = 1000; // 原版 dataFilter.vue width:1000 +constexpr int kBodyH = 500; // 原版 .data-filter-container height:500 +constexpr int kInfoW = 300; // 原版 .filter-options width:300 +constexpr int kInfoLabelW = 120; // 原版 .label min-width:120 +const char* kHighlight = "#f77234"; // 原版橙色高亮(当前点数/原始点数) + +// 信息区一行:定宽 label(左)+ 值(右)。返回值标签供调用方写值/上色。 +QLabel* addInfoRow(QFormLayout* form, const QString& labelText) { + auto* lbl = new QLabel(labelText); + lbl->setMinimumWidth(kInfoLabelW); + auto* val = new QLabel(); + form->addRow(lbl, val); + return val; +} } // namespace ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, @@ -31,43 +51,84 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository vFieldCode_(std::move(vFieldCode)) { setWindowTitle(QStringLiteral("数据过滤")); setModal(true); - resize(640, 300); + resize(kDialogW, kBodyH + 120); // body 500 + 滑块/按钮/边距 - auto* root = formkit::dialogRoot(this); + // 全量有限值 + 数据域 + 原始点数(统计基线)。 + values_.reserve(values.size()); + for (double x : values) + if (std::isfinite(x)) values_.push_back(x); + originalPoints_ = static_cast(values_.size()); + if (!values_.empty()) { + dataMin_ = *std::min_element(values_.begin(), values_.end()); + dataMax_ = *std::max_element(values_.begin(), values_.end()); + } - rangeLabel_ = new QLabel(QStringLiteral("数值范围:—"), this); - root->addWidget(rangeLabel_); + auto* root = new QVBoxLayout(this); + root->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl); + root->setSpacing(space::kLg); - // 主体:左直方图 + 右范围输入(横向分栏,对照原版左图右控件布局)。 + // ── 上半区:左直方图 + 右信息区(高 500)── auto* bodyLay = new QHBoxLayout(); + bodyLay->setSpacing(space::kXl); histogram_ = new ScatterHistogramView(this); - histogram_->setValues(values); + histogram_->setValues(values_); + histogram_->setMinimumHeight(kBodyH); bodyLay->addWidget(histogram_, 1); - auto* card = formkit::formCard(this); - auto* cardLay = formkit::cardBody(card); + // 右信息区(定宽 300,带边框卡片)。 + auto* info = new QFrame(this); + info->setObjectName(QStringLiteral("filterInfo")); + info->setFixedWidth(kInfoW); + applyTokenizedStyleSheet( + info, QStringLiteral("QFrame#filterInfo { border:1px solid {{border/default}};" + " border-radius:4px; }")); + auto* infoLay = new QVBoxLayout(info); + infoLay->setContentsMargins(space::kXl, space::kXl, space::kXl, space::kXl); + infoLay->setSpacing(space::kLg); - auto* form = formkit::makeEditForm(); - minSpin_ = new QDoubleSpinBox(this); - minSpin_->setRange(-kSpinRange, kSpinRange); - minSpin_->setDecimals(2); - formkit::capField(minSpin_); + auto* statForm = new QFormLayout(); + statForm->setLabelAlignment(Qt::AlignLeft); + rangeValueLbl_ = addInfoRow(statForm, QStringLiteral("数值范围:")); + percentLbl_ = addInfoRow(statForm, QStringLiteral("当前数据量占比:")); + auto* origVal = addInfoRow(statForm, QStringLiteral("原始点数:")); + origVal->setText(QString::number(originalPoints_)); + origVal->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮 + currentPtsLbl_ = addInfoRow(statForm, QStringLiteral("当前点数:")); + currentPtsLbl_->setStyleSheet(QStringLiteral("color:%1;").arg(kHighlight)); // 橙色高亮 + infoLay->addLayout(statForm); + + // 最大值在上、最小值在下(对照原版输入框顺序)。 + auto* inputForm = new QFormLayout(); maxSpin_ = new QDoubleSpinBox(this); maxSpin_->setRange(-kSpinRange, kSpinRange); maxSpin_->setDecimals(2); - formkit::capField(maxSpin_); - form->addRow(formkit::editLabel(QStringLiteral("最小值")), minSpin_); - form->addRow(formkit::editLabel(QStringLiteral("最大值")), maxSpin_); - cardLay->addLayout(form); - bodyLay->addWidget(card); - root->addLayout(bodyLay); + minSpin_ = new QDoubleSpinBox(this); + minSpin_->setRange(-kSpinRange, kSpinRange); + minSpin_->setDecimals(2); + inputForm->addRow(new QLabel(QStringLiteral("最大值:")), maxSpin_); + inputForm->addRow(new QLabel(QStringLiteral("最小值:")), minSpin_); + infoLay->addLayout(inputForm); - // min/max 改动 → 实时同步直方图选区高亮(原版输入/滑块联动指示矩形)。 - connect(minSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, - [this](double) { syncHistogramSel(); }); - connect(maxSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, - [this](double) { syncHistogramSel(); }); + // 计算分布 / 重置(信息区中部,对照原版 .filter-actions)。 + auto* actionLay = new QHBoxLayout(); + auto* calcBtn = new QPushButton(QStringLiteral("计算分布"), this); + auto* resetBtn = new QPushButton(QStringLiteral("重置"), this); + actionLay->addStretch(); + actionLay->addWidget(calcBtn); + actionLay->addWidget(resetBtn); + infoLay->addLayout(actionLay); + infoLay->addStretch(); + bodyLay->addWidget(info); + root->addLayout(bodyLay, 1); + + // ── 底部:范围滑块 ── + slider_ = new RangeSlider(this); + slider_->setRange(dataMin_, dataMax_); + slider_->setValues(dataMin_, dataMax_); + root->addWidget(slider_); + + // ── 底部按钮:取消 / 应用过滤(右对齐)── auto* btnLay = new QHBoxLayout(); btnLay->addStretch(); auto* cancelBtn = new QPushButton(QStringLiteral("取消"), this); @@ -77,30 +138,69 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository btnLay->addWidget(applyBtn_); root->addLayout(btnLay); + // ── 三方联动(min/max 输入 ↔ 滑块 ↔ 直方图/统计)── + connect(minSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double v) { setCurrentRange(v, maxSpin_->value(), false, true); }); + connect(maxSpin_, QOverload::of(&QDoubleSpinBox::valueChanged), this, + [this](double v) { setCurrentRange(minSpin_->value(), v, false, true); }); + connect(slider_, &RangeSlider::rangeChanged, this, + [this](double lo, double hi) { setCurrentRange(lo, hi, true, false); }); + connect(calcBtn, &QPushButton::clicked, this, [this]() { refreshStats(); }); + connect(resetBtn, &QPushButton::clicked, this, &ScatterFilterDialog::onResetFilter); connect(cancelBtn, &QPushButton::clicked, this, &QDialog::reject); connect(applyBtn_, &QPushButton::clicked, this, &ScatterFilterDialog::onApply); loadConfig(); } -void ScatterFilterDialog::syncHistogramSel() { - if (histogram_) histogram_->setSelection(minSpin_->value(), maxSpin_->value()); +void ScatterFilterDialog::setCurrentRange(double min, double max, bool fromSlider, bool fromSpin) { + // 同步未发起方的控件(屏蔽信号避免回环),再刷新统计/直方图。 + if (!fromSpin) { + const QSignalBlocker b1(minSpin_); + const QSignalBlocker b2(maxSpin_); + minSpin_->setValue(min); + maxSpin_->setValue(max); + } + if (!fromSlider && slider_) { + const QSignalBlocker b(slider_); + slider_->setValues(min, max); + } + refreshStats(); +} + +void ScatterFilterDialog::refreshStats() { + const double mn = minSpin_->value(); + const double mx = maxSpin_->value(); + rangeValueLbl_->setText(QStringLiteral("%1 — %2") + .arg(QString::number(mn, 'g', 6), QString::number(mx, 'g', 6))); + const int cur = countScatterInRange(values_, mn, mx); + currentPtsLbl_->setText(QString::number(cur)); + const QString pct = (originalPoints_ > 0) + ? QStringLiteral("%1%").arg( + QString::number(100.0 * cur / originalPoints_, 'f', 2)) + : QStringLiteral("0%"); + percentLbl_->setText(pct); + if (histogram_) histogram_->setSelection(mn, mx); +} + +void ScatterFilterDialog::onResetFilter() { + // 重置:恢复到全量数据域(对照原版 resetFilter)。 + setCurrentRange(dataMin_, dataMax_, false, false); } void ScatterFilterDialog::loadConfig() { - if (!repo_) return; + if (!repo_) { + setCurrentRange(dataMin_, dataMax_, false, false); // 无仓储:用数据域初值 + return; + } QPointer self(this); repo_->getScatterFilterConfig( dsObjectId_, vFieldCode_, [self](bool ok, QJsonObject cfg, QString) { - if (!self || !ok) return; + if (!self) return; + if (!ok) { self->setCurrentRange(self->dataMin_, self->dataMax_, false, false); return; } const double mn = cfg.value(QStringLiteral("min")).toDouble(); const double mx = cfg.value(QStringLiteral("max")).toDouble(); - self->minSpin_->setValue(mn); - self->maxSpin_->setValue(mx); - self->rangeLabel_->setText(QStringLiteral("数值范围:%1 — %2") - .arg(QString::number(mn, 'g', 6), - QString::number(mx, 'g', 6))); - self->syncHistogramSel(); // 初值就位后同步直方图选区 + self->setCurrentRange(mn, mx, false, false); }); } @@ -120,8 +220,9 @@ void ScatterFilterDialog::onApply() { if (!self) return; self->applyBtn_->setEnabled(true); if (ok) { - QMessageBox::information(self, self->windowTitle(), - QStringLiteral("应用过滤成功!")); + // 成功提示挂父窗口(对话框随即关闭)。文案对照原版「应用过滤成功!」。 + if (auto* anchor = self->parentWidget()) + showToast(anchor, QStringLiteral("应用过滤成功!")); self->accept(); } else { QMessageBox::warning(self, self->windowTitle(), diff --git a/src/app/panels/chart/ScatterFilterDialog.hpp b/src/app/panels/chart/ScatterFilterDialog.hpp index acdb083..b18536f 100644 --- a/src/app/panels/chart/ScatterFilterDialog.hpp +++ b/src/app/panels/chart/ScatterFilterDialog.hpp @@ -15,34 +15,47 @@ class IDatasetCommandRepository; namespace geopro::app { class ScatterHistogramView; +class RangeSlider; -// 「数据过滤」对话框(复刻原版 web dataFilter.vue): -// 左侧数值分布直方图(自绘 ScatterHistogram,由打开方传入当前 V 值数组), -// 右侧 min/max 输入框;改 min/max 时直方图同步高亮选区(对照原版 .filter-indicator)。 -// 打开时经 getScatterFilterConfig 取 min/max 初值填入输入框; -// 「应用过滤」经 applyScatterFilter 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max})。 +// 「数据过滤」对话框(1:1 复刻原版 web dataFilter.vue,弹窗宽 1000px): +// 左:数值分布直方图(自绘 ScatterHistogram,hover 柱变红 + tooltip); +// 右:信息区(数值范围 / 当前数据量占比 / 原始点数 / 当前点数橙色高亮 + 最大值/最小值输入 + +// 「计算分布」「重置」);底部:范围双手柄滑块 + 「取消」「应用过滤」。 +// min/max 输入、滑块、直方图选区三方联动;统计随当前区间实时更新。 +// 打开时经 getScatterFilterConfig 取 min/max 初值;「应用过滤」经 applyScatterFilter +// 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max});成功提示 toast。 // 回调用 QPointer 守卫(虽 modal exec,仍异步回调)。 class ScatterFilterDialog : public QDialog { Q_OBJECT public: - // values = 当前 V 值数组(与图上散点 v 同源,驱动直方图分布);可空(无值则直方图空态)。 + // values = 当前 V 值数组(与图上散点 v 同源,驱动直方图分布 + 统计);可空(空则空态)。 ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, QString vFieldCode, std::vector values, QWidget* parent = nullptr); private: - void loadConfig(); // 取 min/max 初值 - void onApply(); // 应用过滤 - void syncHistogramSel(); // 把当前 min/max 同步到直方图选区高亮 + void loadConfig(); // 取 min/max 初值 + void onApply(); // 应用过滤 + void onResetFilter(); // 重置:恢复到全量数据域 + void setCurrentRange(double min, double max, bool fromSlider, bool fromSpin); // 三方联动 + void refreshStats(); // 刷新统计(占比/当前点数)与直方图选区 geopro::data::IDatasetCommandRepository* repo_ = nullptr; QString dsObjectId_; QString vFieldCode_; - QLabel* rangeLabel_ = nullptr; // 「数值范围:min — max」 + std::vector values_; // 全量有限 v 值(统计/分箱用) + double dataMin_ = 0.0; // 数据域下界 + double dataMax_ = 0.0; // 数据域上界 + int originalPoints_ = 0; // 原始点数(全量有限值个数) + + QLabel* rangeValueLbl_ = nullptr; // 「数值范围」值 + QLabel* percentLbl_ = nullptr; // 「当前数据量占比」值 + QLabel* currentPtsLbl_ = nullptr; // 「当前点数」值(橙色高亮) QDoubleSpinBox* minSpin_ = nullptr; QDoubleSpinBox* maxSpin_ = nullptr; QPushButton* applyBtn_ = nullptr; ScatterHistogramView* histogram_ = nullptr; // 左侧分布直方图 + RangeSlider* slider_ = nullptr; // 底部范围滑块 }; } // namespace geopro::app diff --git a/src/app/panels/chart/ScatterHistogram.cpp b/src/app/panels/chart/ScatterHistogram.cpp index a7b9b5e..4662fe0 100644 --- a/src/app/panels/chart/ScatterHistogram.cpp +++ b/src/app/panels/chart/ScatterHistogram.cpp @@ -3,8 +3,11 @@ #include #include +#include +#include #include #include +#include #include "panels/chart/ScatterDataOps.hpp" // buildScatterHistogram @@ -12,8 +15,9 @@ namespace geopro::app { namespace { constexpr int kBinCount = 20; // 分箱数(对照原版 D3 stepRange=20) -const QColor kBarIn(64, 128, 255); // 选区内柱:蓝(对照原版高亮) +const QColor kBarIn(64, 128, 255); // 选区内柱:蓝(对照原版 #4080FF) const QColor kBarOut(200, 205, 215); // 选区外柱:灰 +const QColor kBarHover(245, 63, 63); // hover 柱:红(对照原版 #F53F3F) const QColor kIndicator(64, 128, 255, 51); // 选区指示矩形:rgba(64,128,255,0.2) const QColor kAxis(150, 150, 150); // 轴线/刻度 constexpr int kPadL = 8; // 左右内边距 @@ -26,6 +30,7 @@ constexpr int kBarGap = 1; // 柱间距(像素) ScatterHistogramView::ScatterHistogramView(QWidget* parent) : QWidget(parent) { setMinimumHeight(160); setMinimumWidth(280); + setMouseTracking(true); // 无按键也接收 MouseMove,用于 hover 高亮 } void ScatterHistogramView::setValues(const std::vector& values) { @@ -39,6 +44,7 @@ void ScatterHistogramView::setValues(const std::vector& values) { dataMin_ = *std::min_element(values_.begin(), values_.end()); dataMax_ = *std::max_element(values_.begin(), values_.end()); } + hoverBin_ = -1; update(); } @@ -49,6 +55,54 @@ void ScatterHistogramView::setSelection(double min, double max) { update(); } +int ScatterHistogramView::binAtX(int px) const { + if (values_.empty() || !(dataMax_ > dataMin_)) return -1; + const QRect r = rect(); + const int plotL = r.left() + kPadL; + const int plotR = r.right() - kPadR; + const int plotW = plotR - plotL; + if (plotW <= 0) return -1; + const double binW = static_cast(plotW) / kBinCount; + if (binW <= 0) return -1; + const int idx = static_cast((px - plotL) / binW); + if (idx < 0 || idx >= kBinCount) return -1; + return idx; +} + +void ScatterHistogramView::mouseMoveEvent(QMouseEvent* event) { + const int bin = binAtX(event->position().toPoint().x()); + if (bin != hoverBin_) { + hoverBin_ = bin; + update(); + } + if (bin >= 0 && dataMax_ > dataMin_) { + // tooltip:数值范围 + 数据点数量(对照原版 D3 tooltip 两行)。 + const double step = (dataMax_ - dataMin_) / kBinCount; + const double lo = dataMin_ + bin * step; + const double hi = lo + step; + const auto h = buildScatterHistogram(values_, dataMin_, dataMax_, kBinCount); + const int cnt = (bin < static_cast(h.counts.size())) ? h.counts[static_cast(bin)] : 0; + QToolTip::showText(event->globalPosition().toPoint(), + QStringLiteral("数值范围: %1 - %2\n数据点数量: %3") + .arg(QString::number(std::llround(lo)), + QString::number(std::llround(hi)), + QString::number(cnt)), + this); + } else { + QToolTip::hideText(); + } + QWidget::mouseMoveEvent(event); +} + +void ScatterHistogramView::leaveEvent(QEvent* event) { + if (hoverBin_ != -1) { + hoverBin_ = -1; + update(); + } + QToolTip::hideText(); + QWidget::leaveEvent(event); +} + void ScatterHistogramView::paintEvent(QPaintEvent*) { QPainter p(this); p.setRenderHint(QPainter::Antialiasing, false); @@ -93,7 +147,7 @@ void ScatterHistogramView::paintEvent(QPaintEvent*) { p.fillRect(ind, kIndicator); } - // 画柱:高度按计数归一;选区内蓝、选区外灰。 + // 画柱:高度按计数归一;hover 柱红;其余按选区内蓝/外灰。 const double binW = static_cast(plotW) / n; for (int i = 0; i < n; ++i) { const double binLo = dataMin_ + i * h.step; @@ -102,10 +156,11 @@ void ScatterHistogramView::paintEvent(QPaintEvent*) { maxCount * plotH); const int bx = static_cast(plotL + i * binW) + kBarGap; const int bw = std::max(1, static_cast(binW) - 2 * kBarGap); - // 柱中心落在选区内 → 高亮(与原版“区间内点高亮”观感一致)。 + // 柱中心落在选区内 → 高亮蓝(与原版“区间内点高亮”观感一致);hover 优先红。 const double binCenter = (binLo + binHi) / 2.0; const bool inSel = hasSel_ && binCenter >= selMin_ && binCenter <= selMax_; - p.fillRect(bx, plotBottom - barH, bw, barH, inSel ? kBarIn : kBarOut); + const QColor c = (i == hoverBin_) ? kBarHover : (inSel ? kBarIn : kBarOut); + p.fillRect(bx, plotBottom - barH, bw, barH, c); } // 底部基线 + 两端数值刻度(min/max)。 diff --git a/src/app/panels/chart/ScatterHistogram.hpp b/src/app/panels/chart/ScatterHistogram.hpp index 8a40c9d..d325fcb 100644 --- a/src/app/panels/chart/ScatterHistogram.hpp +++ b/src/app/panels/chart/ScatterHistogram.hpp @@ -8,7 +8,8 @@ namespace geopro::app { // 数值分布直方图(M3 数据过滤对话框左侧分布图,复刻原版 dataFilter.vue 的 D3 直方图)。 // 自绘 QWidget:按全量 v 值的数据域分箱画柱;当前选定 [min,max] 区间内的柱高亮蓝、区间外灰, // 并在选区上叠加半透明蓝色指示矩形(对照原版 .filter-indicator)。x 轴底部画数值刻度。 -// 选区由对话框输入框/范围联动 setSelection 更新(本控件只读展示,不发选择信号)。 +// 选区由对话框输入框/范围联动 setSelection 更新。 +// hover:鼠标悬停某柱时该柱变红(对照原版 #F53F3F)+ QToolTip 显示「数值范围 / 数据点数量」。 // 命名加 View 后缀以与 ScatterDataOps.hpp 的数据结构 struct ScatterHistogram(分箱结果)区分, // 避免同名 geopro::app::ScatterHistogram 在同一 TU 内冲突。 class ScatterHistogramView : public QWidget { @@ -23,14 +24,20 @@ public: protected: void paintEvent(QPaintEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; // hover 高亮 + tooltip + void leaveEvent(QEvent* event) override; // 离开清 hover private: + // 命中坐标 x(像素)所在的柱索引;未命中返回 -1。绘制/命中共用同一映射,保证一致。 + int binAtX(int px) const; + std::vector values_; // 全量有限 v 值(已过滤 NaN/inf) double dataMin_ = 0.0; // 数据域下界(分箱总区间) double dataMax_ = 0.0; // 数据域上界 double selMin_ = 0.0; // 当前选区下界 double selMax_ = 0.0; // 当前选区上界 bool hasSel_ = false; // 选区有效(selMin<=selMax) + int hoverBin_ = -1; // 当前 hover 的柱索引(-1=无) }; } // namespace geopro::app diff --git a/tests/app/test_scatter_data_ops.cpp b/tests/app/test_scatter_data_ops.cpp index 49730af..2ac5286 100644 --- a/tests/app/test_scatter_data_ops.cpp +++ b/tests/app/test_scatter_data_ops.cpp @@ -122,3 +122,12 @@ TEST(ScatterDataOps, HistogramDegenerateReturnsEmpty) { EXPECT_TRUE(buildScatterHistogram(v, 5.0, 5.0, 10).counts.empty()); // min==max EXPECT_TRUE(buildScatterHistogram(v, 0.0, 10.0, 0).counts.empty()); // binCount<=0 } + +TEST(ScatterDataOps, CountInRangeClosedInterval) { + std::vector v{0, 5, 10, 15, -3, std::nan("")}; + // 闭区间 [0,10]:0,5,10 计入;15、-3 区间外;NaN 跳过。 + EXPECT_EQ(countScatterInRange(v, 0.0, 10.0), 3); + EXPECT_EQ(countScatterInRange(v, 5.0, 5.0), 1); // 单点闭区间 + EXPECT_EQ(countScatterInRange(v, 10.0, 0.0), 0); // max