fix(detail): measurement 对话框/工具条视觉返工对齐原版

以原版 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 绿。
This commit is contained in:
gaozheng 2026-06-23 11:53:13 +08:00
parent 687edfeca1
commit c21226a3d7
13 changed files with 476 additions and 71 deletions

View File

@ -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

View File

@ -0,0 +1,104 @@
#include "panels/chart/RangeSlider.hpp"
#include <algorithm>
#include <cmath>
#include <QMouseEvent>
#include <QPainter>
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<int>((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<double>(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

View File

@ -0,0 +1,40 @@
#pragma once
#include <QWidget>
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

View File

@ -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<geopro::core::FieldOption>& 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
// 持久化到后端saveColorGradationbusinessCode=当前 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<double>& v, std::size_t i) {
return i < v.size() ? v[i] : 0.0;
};
// 复刻原版 scatterInfosA / 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)));
// 复刻原版 scatterInfosA / B / M / N / DataRow / Pseu_ResisA/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.vueX=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<int>::of(&QComboBox::currentIndexChanged), this,
[this](int) { replotForAxis(); });
connect(yCombo_, QOverload<int>::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);

View File

@ -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 橙#F4B008DataRow/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 重建

View File

@ -13,6 +13,7 @@
#include <QVBoxLayout>
#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 {
// ── RawDatameasurement新增/覆盖 + 名称(保持既有逻辑,不在本批返工范围)──
// ── RawDatameasurement新增/覆盖 + 名称(对照原版「数据另存为」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(),

View File

@ -87,4 +87,14 @@ int toggledDisplayStatus(int currentStatus) {
return currentStatus == 0 ? 1 : 0; // 0 显示 → 1 隐藏;其余 → 0 显示
}
int countScatterInRange(const std::vector<double>& 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

View File

@ -59,4 +59,8 @@ ScatterHistogram buildScatterHistogram(const std::vector<double>& v, double min,
// 入参/返回 0=显示、1=隐藏。当前显示(0) → 隐藏(1);当前隐藏(非0) → 显示(0)。
int toggledDisplayStatus(int currentStatus);
// 统计落在闭区间 [min,max] 内的有限值个数M3 数据过滤「当前点数」/占比用)。
// 非有限值NaN/inf不计入max<min → 0。
int countScatterInRange(const std::vector<double>& v, double min, double max);
} // namespace geopro::app

View File

@ -1,18 +1,24 @@
#include "panels/chart/ScatterFilterDialog.hpp"
#include <algorithm>
#include <cmath>
#include <utility>
#include <QDoubleSpinBox>
#include <QFormLayout>
#include <QFrame>
#include <QHBoxLayout>
#include <QLabel>
#include <QMessageBox>
#include <QPointer>
#include <QPushButton>
#include <QSignalBlocker>
#include <QVBoxLayout>
#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<int>(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<double>::of(&QDoubleSpinBox::valueChanged), this,
[this](double) { syncHistogramSel(); });
connect(maxSpin_, QOverload<double>::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<double>::of(&QDoubleSpinBox::valueChanged), this,
[this](double v) { setCurrentRange(v, maxSpin_->value(), false, true); });
connect(maxSpin_, QOverload<double>::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<ScatterFilterDialog> 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(),

View File

@ -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
// 左:数值分布直方图(自绘 ScatterHistogramhover 柱变红 + 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<double> values, QWidget* parent = nullptr);
private:
void loadConfig(); // 取 min/max 初值
void onApply(); // 应用过滤
void syncHistogramSel(); // 把当前 min/max 同步到直方图选区高亮
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<double> 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

View File

@ -3,8 +3,11 @@
#include <algorithm>
#include <cmath>
#include <QEvent>
#include <QMouseEvent>
#include <QPaintEvent>
#include <QPainter>
#include <QToolTip>
#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<double>& values) {
@ -39,6 +44,7 @@ void ScatterHistogramView::setValues(const std::vector<double>& 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<double>(plotW) / kBinCount;
if (binW <= 0) return -1;
const int idx = static_cast<int>((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<int>(h.counts.size())) ? h.counts[static_cast<std::size_t>(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<double>(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<int>(plotL + i * binW) + kBarGap;
const int bw = std::max(1, static_cast<int>(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

View File

@ -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<double> 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

View File

@ -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<double> 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<min → 0
EXPECT_EQ(countScatterInRange(v, 100.0, 200.0), 0); // 全在区间外
}