feat/vtk-3d-view #7

Merged
gaozheng merged 301 commits from feat/vtk-3d-view into main 2026-06-27 18:43:52 +08:00
34 changed files with 1759 additions and 85 deletions
Showing only changes of commit ec4a7e81ef - Show all commits

View File

@ -38,12 +38,14 @@ add_executable(geopro_desktop WIN32
panels/DatasetAttrPanel.cpp panels/DatasetAttrPanel.cpp
panels/ObjectExceptionPanel.cpp panels/ObjectExceptionPanel.cpp
panels/DescriptionPanel.cpp panels/DescriptionPanel.cpp
panels/QuillDelta.cpp
panels/chart/RawDataChartView.cpp panels/chart/RawDataChartView.cpp
panels/chart/InversionFormDialog.cpp panels/chart/InversionFormDialog.cpp
panels/chart/InversionFormParse.cpp panels/chart/InversionFormParse.cpp
panels/chart/ScatterDataOps.cpp panels/chart/ScatterDataOps.cpp
panels/chart/SaveAsDialog.cpp panels/chart/SaveAsDialog.cpp
panels/chart/ScatterFilterDialog.cpp panels/chart/ScatterFilterDialog.cpp
panels/chart/ScatterHistogram.cpp
panels/chart/InversionProcessOps.cpp panels/chart/InversionProcessOps.cpp
panels/chart/GridWizardDialog.cpp panels/chart/GridWizardDialog.cpp
panels/chart/WhiteningDialog.cpp panels/chart/WhiteningDialog.cpp
@ -70,6 +72,9 @@ add_executable(geopro_desktop WIN32
panels/chart/ContourPlotItem.cpp panels/chart/ContourPlotItem.cpp
panels/chart/LivePanner.cpp panels/chart/LivePanner.cpp
panels/chart/ScatterHoverTip.cpp panels/chart/ScatterHoverTip.cpp
panels/chart/ChartPickGeometry.cpp
panels/chart/ScatterMarqueePicker.cpp
panels/chart/ContourDrawTool.cpp
panels/columns/Column2DDataset.cpp panels/columns/Column2DDataset.cpp
panels/columns/Column3DDataset.cpp panels/columns/Column3DDataset.cpp
panels/columns/Column3DAnalysis.cpp panels/columns/Column3DAnalysis.cpp

View File

@ -1,20 +1,39 @@
#include "panels/DescriptionPanel.hpp" #include "panels/DescriptionPanel.hpp"
#include <QColorDialog>
#include <QComboBox>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QPushButton> #include <QPushButton>
#include <QTextCharFormat>
#include <QTextEdit> #include <QTextEdit>
#include <QTextListFormat>
#include <QToolBar>
#include <QToolButton>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "Theme.hpp" #include "Theme.hpp"
#include "panels/QuillDelta.hpp"
namespace geopro::app { namespace geopro::app {
namespace {
// 字号下拉选项px——对照原版 ql-size 的 12~32px。
const int kFontSizesPx[] = {12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32};
} // namespace
DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) { DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
auto* lay = new QVBoxLayout(this); auto* lay = new QVBoxLayout(this);
lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd, lay->setContentsMargins(geopro::app::space::kMd, geopro::app::space::kMd,
geopro::app::space::kMd, geopro::app::space::kMd); geopro::app::space::kMd, geopro::app::space::kMd);
lay->setSpacing(geopro::app::space::kSm);
auto* tb = new QToolBar(this);
buildToolbar(tb);
lay->addWidget(tb);
edit_ = new QTextEdit(this); edit_ = new QTextEdit(this);
edit_->setAcceptRichText(true);
edit_->setPlaceholderText(QStringLiteral("暂无描述")); edit_->setPlaceholderText(QStringLiteral("暂无描述"));
lay->addWidget(edit_, 1); lay->addWidget(edit_, 1);
@ -25,13 +44,96 @@ DescriptionPanel::DescriptionPanel(QWidget* parent) : QWidget(parent) {
btnLay->addWidget(saveBtn_); btnLay->addWidget(saveBtn_);
lay->addLayout(btnLay); lay->addLayout(btnLay);
connect(saveBtn_, &QPushButton::clicked, this, connect(saveBtn_, &QPushButton::clicked, this, [this]() { emit saveRequested(); });
[this]() { emit saveRequested(edit_->toPlainText()); });
} }
void DescriptionPanel::setText(const QString& text) { edit_->setPlainText(text); } void DescriptionPanel::buildToolbar(QToolBar* tb) {
// 粗体 / 斜体 / 下划线:可勾选按钮,作用于选区当前字符格式。
auto addToggle = [this, tb](const QString& label, auto applier) {
auto* btn = new QToolButton(tb);
btn->setText(label);
btn->setCheckable(true);
tb->addWidget(btn);
connect(btn, &QToolButton::toggled, this, applier);
return btn;
};
addToggle(QStringLiteral("B"), [this](bool on) {
QTextCharFormat f;
f.setFontWeight(on ? QFont::Bold : QFont::Normal);
edit_->mergeCurrentCharFormat(f);
});
addToggle(QStringLiteral("I"), [this](bool on) {
QTextCharFormat f;
f.setFontItalic(on);
edit_->mergeCurrentCharFormat(f);
});
addToggle(QStringLiteral("U"), [this](bool on) {
QTextCharFormat f;
f.setFontUnderline(on);
edit_->mergeCurrentCharFormat(f);
});
QString DescriptionPanel::text() const { return edit_->toPlainText(); } // 字色:弹色板,作用于选区前景色。
auto* colorBtn = new QToolButton(tb);
colorBtn->setText(QStringLiteral("A"));
tb->addWidget(colorBtn);
connect(colorBtn, &QToolButton::clicked, this, [this]() {
const QColor c = QColorDialog::getColor(Qt::black, this, QStringLiteral("字体颜色"));
if (!c.isValid()) return;
QTextCharFormat f;
f.setForeground(c);
edit_->mergeCurrentCharFormat(f);
});
// 字号下拉px
auto* sizeBox = new QComboBox(tb);
for (int px : kFontSizesPx) sizeBox->addItem(QStringLiteral("%1px").arg(px), px);
sizeBox->setCurrentIndex(2); // 默认 16px与原版一致
tb->addWidget(sizeBox);
connect(sizeBox, &QComboBox::currentIndexChanged, this, [this, sizeBox](int) {
const int px = sizeBox->currentData().toInt();
QTextCharFormat f;
f.setFontPointSize(px * 3.0 / 4.0); // px→pt。
edit_->mergeCurrentCharFormat(f);
});
// 标题下拉(正文 / H1~H4——块级作用于当前段。
auto* headerBox = new QComboBox(tb);
headerBox->addItem(QStringLiteral("正文"), 0);
for (int h = 1; h <= 4; ++h) headerBox->addItem(QStringLiteral("标题%1").arg(h), h);
tb->addWidget(headerBox);
connect(headerBox, &QComboBox::currentIndexChanged, this, [this, headerBox](int) {
const int h = headerBox->currentData().toInt();
QTextCursor cur = edit_->textCursor();
QTextBlockFormat bf = cur.blockFormat();
bf.setHeadingLevel(h); // 0 表示正文。
cur.mergeBlockFormat(bf);
edit_->setTextCursor(cur);
});
// 有序 / 无序列表——块级。
auto addListBtn = [this, tb](const QString& label, QTextListFormat::Style style) {
auto* btn = new QToolButton(tb);
btn->setText(label);
tb->addWidget(btn);
connect(btn, &QToolButton::clicked, this, [this, style]() {
edit_->textCursor().createList(style);
});
};
addListBtn(QStringLiteral("1."), QTextListFormat::ListDecimal);
addListBtn(QStringLiteral(""), QTextListFormat::ListDisc);
}
void DescriptionPanel::setDelta(const QJsonArray& ops) {
if (ops.isEmpty()) return;
deltaToDocument(ops, *edit_->document());
}
void DescriptionPanel::setPlainText(const QString& text) { edit_->setPlainText(text); }
QJsonArray DescriptionPanel::delta() const { return documentToDelta(*edit_->document()); }
QString DescriptionPanel::plainText() const { return edit_->toPlainText(); }
void DescriptionPanel::setSaveEnabled(bool on) { saveBtn_->setEnabled(on); } void DescriptionPanel::setSaveEnabled(bool on) { saveBtn_->setEnabled(on); }

View File

@ -1,25 +1,38 @@
#pragma once #pragma once
#include <QJsonArray>
#include <QWidget> #include <QWidget>
class QTextEdit; class QTextEdit;
class QPushButton; class QPushButton;
class QToolBar;
namespace geopro::app { namespace geopro::app {
// 数据集描述面板:可编辑文本 + 保存按钮I14 // 数据集描述面板:富文本编辑器 + 格式工具栏 + 保存按钮I14
// 原版用 Quill 富文本DeltaQt 无对应控件 → 退化为纯文本编辑 + 保存; // 对照原版 webcontourPage.vue的 Quill 编辑器:粗体/斜体/下划线/字色/字号 +
// 保存时由调用方组装 {description, attachedParameters:{deltaContent}}(见 GridDataChartView // 有序/无序列表 + 标题。保存时把富文本转 Quill DeltaattachedParameters.deltaContent
// 与纯文本description一并提交组装/请求见 GridDataChartView
// Delta↔QTextDocument 互转见 QuillDelta.{hpp,cpp}(纯函数,可单测)。
class DescriptionPanel : public QWidget { class DescriptionPanel : public QWidget {
Q_OBJECT Q_OBJECT
public: public:
explicit DescriptionPanel(QWidget* parent = nullptr); explicit DescriptionPanel(QWidget* parent = nullptr);
void setText(const QString& text);
QString text() const; // 用 Quill Delta ops 回填编辑器(无 ops 时回退 setPlainText 兜底)。
void setDelta(const QJsonArray& ops);
void setPlainText(const QString& text);
// 当前内容导出Delta ops与原版 deltaContent 兼容)+ 纯文本description
QJsonArray delta() const;
QString plainText() const;
// 注入「保存」可用性:无 cmdRepo/dsId 时禁用保存按钮(占位)。 // 注入「保存」可用性:无 cmdRepo/dsId 时禁用保存按钮(占位)。
void setSaveEnabled(bool on); void setSaveEnabled(bool on);
signals: signals:
void saveRequested(const QString& text); void saveRequested();
private: private:
void buildToolbar(QToolBar* tb);
QTextEdit* edit_; QTextEdit* edit_;
QPushButton* saveBtn_; QPushButton* saveBtn_;
}; };

View File

@ -0,0 +1,163 @@
#include "panels/QuillDelta.hpp"
#include <QColor>
#include <QJsonObject>
#include <QTextBlock>
#include <QTextCharFormat>
#include <QTextDocument>
#include <QTextList>
namespace geopro::app {
namespace {
// ── 序列化方向QTextDocument → Delta─────────────────────────────────────
// 颜色转 Quill 习惯的小写 #rrggbb与原版 ql-color/ql-background 选项一致)。
QString hexOf(const QColor& c) { return c.name(QColor::HexRgb); }
// 行内样式 → attributes 对象bold/italic/underline/color/background/size
QJsonObject inlineAttrs(const QTextCharFormat& fmt) {
QJsonObject a;
if (fmt.fontWeight() >= QFont::Bold) a[QStringLiteral("bold")] = true;
if (fmt.fontItalic()) a[QStringLiteral("italic")] = true;
if (fmt.fontUnderline()) a[QStringLiteral("underline")] = true;
if (fmt.foreground().style() != Qt::NoBrush)
a[QStringLiteral("color")] = hexOf(fmt.foreground().color());
if (fmt.background().style() != Qt::NoBrush)
a[QStringLiteral("background")] = hexOf(fmt.background().color());
const double pt = fmt.fontPointSize();
if (pt > 0.0) // 以 px 表达(原版 ql-size 用 "NNpx"pt→px 约 *4/3 取整)。
a[QStringLiteral("size")] = QStringLiteral("%1px").arg(qRound(pt * 4.0 / 3.0));
return a;
}
// 块级样式 → attributes 对象header/list/align挂在换行 op 上。
QJsonObject blockAttrs(const QTextBlock& block) {
QJsonObject a;
const int headingLevel = block.blockFormat().headingLevel();
if (headingLevel >= 1 && headingLevel <= 4) a[QStringLiteral("header")] = headingLevel;
if (QTextList* lst = block.textList()) {
const QTextListFormat::Style s = lst->format().style();
a[QStringLiteral("list")] = (s == QTextListFormat::ListDecimal)
? QStringLiteral("ordered")
: QStringLiteral("bullet");
}
switch (block.blockFormat().alignment() & Qt::AlignHorizontal_Mask) {
case Qt::AlignHCenter: a[QStringLiteral("align")] = QStringLiteral("center"); break;
case Qt::AlignRight: a[QStringLiteral("align")] = QStringLiteral("right"); break;
case Qt::AlignJustify: a[QStringLiteral("align")] = QStringLiteral("justify"); break;
default: break; // 左对齐为默认,不写 attribute。
}
return a;
}
// 追加一个文本 op带可选 attributes
void pushInsert(QJsonArray& ops, const QString& text, const QJsonObject& attrs) {
if (text.isEmpty()) return;
QJsonObject op{{QStringLiteral("insert"), text}};
if (!attrs.isEmpty()) op[QStringLiteral("attributes")] = attrs;
ops.append(op);
}
// 追加一个「换行」op带可选块级 attributes。Quill 中块级样式作用于其前整行。
void pushNewline(QJsonArray& ops, const QJsonObject& attrs) {
pushInsert(ops, QStringLiteral("\n"), attrs);
}
// 序列化单个文本块的所有 fragment按行内样式分段
void serializeBlock(const QTextBlock& block, QJsonArray& ops) {
for (auto it = block.begin(); it != block.end(); ++it) {
const QTextFragment frag = it.fragment();
if (frag.isValid()) pushInsert(ops, frag.text(), inlineAttrs(frag.charFormat()));
}
}
// ── 反序列化方向Delta → QTextDocument──────────────────────────────────
// 把 inline attributes 应用到字符格式。
void applyInlineAttrs(const QJsonObject& attrs, QTextCharFormat& fmt) {
if (attrs.value(QStringLiteral("bold")).toBool()) fmt.setFontWeight(QFont::Bold);
if (attrs.value(QStringLiteral("italic")).toBool()) fmt.setFontItalic(true);
if (attrs.value(QStringLiteral("underline")).toBool()) fmt.setFontUnderline(true);
const QString color = attrs.value(QStringLiteral("color")).toString();
if (QColor(color).isValid()) fmt.setForeground(QColor(color));
const QString bg = attrs.value(QStringLiteral("background")).toString();
if (QColor(bg).isValid()) fmt.setBackground(QColor(bg));
QString size = attrs.value(QStringLiteral("size")).toString();
if (size.endsWith(QStringLiteral("px"))) {
const double px = size.chopped(2).toDouble();
if (px > 0.0) fmt.setFontPointSize(px * 3.0 / 4.0); // px→pt。
}
}
// 把 block attributes 应用到块格式 / 列表(作用于换行前的当前块)。
void applyBlockAttrs(const QJsonObject& attrs, QTextCursor& cur) {
QTextBlockFormat bf = cur.blockFormat();
const int header = attrs.value(QStringLiteral("header")).toInt();
if (header >= 1 && header <= 4) bf.setHeadingLevel(header);
const QString align = attrs.value(QStringLiteral("align")).toString();
if (align == QStringLiteral("center")) bf.setAlignment(Qt::AlignHCenter);
else if (align == QStringLiteral("right")) bf.setAlignment(Qt::AlignRight);
else if (align == QStringLiteral("justify")) bf.setAlignment(Qt::AlignJustify);
cur.setBlockFormat(bf);
const QString list = attrs.value(QStringLiteral("list")).toString();
if (list == QStringLiteral("ordered")) cur.createList(QTextListFormat::ListDecimal);
else if (list == QStringLiteral("bullet")) cur.createList(QTextListFormat::ListDisc);
}
// 写入一段不含换行的文本(带行内样式)。
void insertSegment(QTextCursor& cur, const QString& text, const QJsonObject& attrs) {
QTextCharFormat fmt;
applyInlineAttrs(attrs, fmt);
cur.insertText(text, fmt);
}
// 处理单个 insert op按换行拆段。每个换行先对当前块应用块级样式header/list/align
// 再开新块承接后续内容。Delta 末尾必有一个收尾换行,会多造一个尾部空块,由
// deltaToDocument 末尾统一裁掉(与 QTextDocument 起始即含一个空块的语义对齐)。
void applyOp(const QString& insert, const QJsonObject& attrs, QTextCursor& cur) {
const QStringList lines = insert.split(QLatin1Char('\n'));
for (int i = 0; i < lines.size(); ++i) {
insertSegment(cur, lines.at(i), attrs);
if (i + 1 < lines.size()) { // 该位置原本是一个换行符。
applyBlockAttrs(attrs, cur); // 块级样式作用于换行之前的整行。
cur.insertBlock();
}
}
}
// 裁掉文档末尾因 Delta 收尾换行而多出的空块(仅当其确为空且非唯一块)。
void trimTrailingEmptyBlock(QTextDocument& doc) {
if (doc.blockCount() <= 1) return;
const QTextBlock last = doc.lastBlock();
if (!last.text().isEmpty()) return;
QTextCursor cur(&doc);
cur.movePosition(QTextCursor::End);
cur.deletePreviousChar(); // 删除上一个块结尾的换行,合并末尾空块。
}
} // namespace
QJsonArray documentToDelta(const QTextDocument& doc) {
QJsonArray ops;
for (QTextBlock block = doc.begin(); block.isValid(); block = block.next()) {
serializeBlock(block, ops);
// 每个块以换行 op 收尾,携带该块的块级样式(最后一个块也写,对应 Quill 末尾换行)。
pushNewline(ops, blockAttrs(block));
}
return ops;
}
void deltaToDocument(const QJsonArray& ops, QTextDocument& doc) {
doc.clear();
QTextCursor cur(&doc);
for (const QJsonValue& v : ops) {
const QJsonObject op = v.toObject();
const QJsonValue insert = op.value(QStringLiteral("insert"));
if (!insert.isString()) continue; // 仅支持文本 insert图片/嵌入等降级丢弃)。
applyOp(insert.toString(), op.value(QStringLiteral("attributes")).toObject(), cur);
}
trimTrailingEmptyBlock(doc);
}
} // namespace geopro::app

View File

@ -0,0 +1,32 @@
#pragma once
#include <QJsonArray>
class QTextDocument;
namespace geopro::app {
// Quill Delta ↔ QTextDocument 互转(纯函数,仅依赖 Qt Core/Gui无 Widgets/MOC
//
// 背景:原版 webcontourPage.vue描述用 Quill 富文本,保存
// attachedParameters.deltaContent = quill.getContents().opsQuill Delta ops 数组),
// description = quill.getText()(纯文本)。读取时 quill.setContents(deltaContent)。
// 客户端无 Quill这里用 QTextDocument 承载富文本,并在两种表示间转换以与原版互通。
//
// 边界(无法做到字节级 1:1目标是「常见格式往返可用」
// - 支持的 inline attributesbold / italic / underline / color / background / size"NNpx")。
// - 支持的 block attributes挂在换行 op 上header1-4/ list"ordered"|"bullet"/ align。
// - 不支持的 attributes 容错降级:保留 insert 文本,丢弃无法表达的样式,不崩。
// - 拆出独立 TU 便于 gtest往返断言常见格式
//
// Quill Delta 结构ops 为数组,每个 op = { "insert": "文本", "attributes": {...} }。
// 行内样式挂在文本 op 上;块级样式(标题/列表/对齐挂在「单个换行」op 的 attributes 上,
// 作用于该换行之前的整行。文档以隐含的「最后一个换行」结尾。
// 把 QTextDocument 序列化为 Quill Delta ops 数组(与原版 quill.getContents().ops 兼容)。
QJsonArray documentToDelta(const QTextDocument& doc);
// 把 Quill Delta ops 数组反序列化进 QTextDocument与原版 quill.setContents(ops) 对应)。
// 先清空 doc 再写入;无法识别的 attributes 跳过。
void deltaToDocument(const QJsonArray& ops, QTextDocument& doc);
} // namespace geopro::app

View File

@ -0,0 +1,38 @@
#include "panels/chart/ChartPickGeometry.hpp"
#include <algorithm>
#include <cmath>
namespace geopro::app {
std::vector<int> pointsInRect(const geopro::core::ScatterField& field, const QRectF& rect) {
std::vector<int> hits;
const auto& xs = field.x;
const auto& ys = field.y;
const std::size_t n = std::min(xs.size(), ys.size());
const bool hasStatus = field.displayStatus.size() == n;
for (std::size_t i = 0; i < n; ++i) {
const double x = xs[i];
const double y = ys[i];
if (!std::isfinite(x) || !std::isfinite(y)) continue; // 脏数据跳过
if (hasStatus && field.displayStatus[i] != 0) continue; // 隐藏点不参与框选
if (rect.contains(x, y)) hits.push_back(static_cast<int>(i));
}
return hits;
}
int minPointsForMarkType(int markType) {
if (markType == 2) return 2; // 线
if (markType == 3) return 3; // 面
return 1; // 点(1)/文字(4)
}
std::vector<QPointF> normalizeDrawnPoints(const std::vector<QPointF>& pts, int markType) {
if (pts.empty()) return {};
// 点/文字:单点定位,仅取首点(即便误收集多点)。
if (markType == 1 || markType == 4) return {pts.front()};
// 线/面:保留全部顶点(不足/闭合由调用方校验)。
return pts;
}
} // namespace geopro::app

View File

@ -0,0 +1,32 @@
#pragma once
#include <vector>
#include <QPointF>
#include <QRectF>
#include "model/Field.hpp"
namespace geopro::app {
// 图上交互的纯几何逻辑(无 Qt Widgets / Qwt 依赖,可独立单测)。
// M14 框选命中 + I9 绘形归一化共用。
// M14 框选命中:返回散点 x/y 落在数据坐标矩形 rect 内的下标集合。
// rect 用数据坐标(调用方已把像素橡皮筋反变换为数据坐标,并 normalized
// 只测有限值x/y 长度不一致取 min 防越界隐藏点displayStatus!=0跳过与原版
// box-select 仅命中可见点一致)。
std::vector<int> pointsInRect(const geopro::core::ScatterField& field, const QRectF& rect);
// I9 绘形:判断多边形是否「可闭合」(至少 3 个顶点。线≥2、点/文字==1 的最少点数判断
// 见 ExceptionGeometry::minPointsForMarkType。此处仅多边形语义糖。
inline bool canClosePolygon(int vertexCount) { return vertexCount >= 3; }
// I9 绘形:把一串数据坐标点裁成「该标注类型的有效几何」:
// 点(1)/文字(4) → 仅取首点;线(2) → 全部(至少 2面(3) → 全部(至少 3
// 超出/不足由调用方在完成时校验minPointsForMarkType。本函数仅做截断点/文字取首点)。
std::vector<QPointF> normalizeDrawnPoints(const std::vector<QPointF>& pts, int markType);
// I9 绘形各标注类型的最少点数点1/线2/面3/文字1。markType 为 "1".."4" 对应的整数。
int minPointsForMarkType(int markType);
} // namespace geopro::app

View File

@ -0,0 +1,222 @@
#include "panels/chart/ContourDrawTool.hpp"
#include <QEvent>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QPainter>
#include <QPen>
#include <QPolygon>
#include <QResizeEvent>
#include <QToolTip>
#include <QWidget>
#include <qwt_plot.h>
#include <qwt_plot_canvas.h>
#include <qwt_scale_map.h>
#include "panels/chart/ChartPickGeometry.hpp"
namespace geopro::app {
// 透明预览覆盖层(贴 canvas画已落点 + 连线 + 到光标的橡皮筋。无 Q_OBJECT仅重写
// paintEvent无信号槽不入 MOC。透明且不吃事件穿透给 canvas 上的 ContourDrawTool
class ContourDrawOverlay : public QWidget {
public:
explicit ContourDrawOverlay(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_TransparentForMouseEvents, true);
setAttribute(Qt::WA_NoSystemBackground, true);
setAttribute(Qt::WA_TranslucentBackground, true);
}
// 设当前绘制态(像素坐标点 + 光标 + 类型。markType 3=面(闭合预览)。
void setState(const QVector<QPoint>& pts, const QPoint& cursor, int markType, bool drawing) {
pts_ = pts;
cursor_ = cursor;
markType_ = markType;
drawing_ = drawing;
update();
}
protected:
void paintEvent(QPaintEvent*) override {
if (!drawing_) return;
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, true);
const QColor accent(24, 144, 255); // 品牌蓝预览
QPen pen(accent, 1.5);
p.setPen(pen);
p.setBrush(Qt::NoBrush);
// 已落点连线(线/面)。
if (pts_.size() >= 2) p.drawPolyline(QPolygon(pts_));
// 橡皮筋:最后一点 → 光标。
if (!pts_.isEmpty() && !cursor_.isNull()) {
QPen dash(accent, 1.2, Qt::DashLine);
p.setPen(dash);
p.drawLine(pts_.back(), cursor_);
// 面:光标 → 首点(闭合预览)。
if (markType_ == 3 && pts_.size() >= 2) p.drawLine(cursor_, pts_.front());
p.setPen(pen);
}
// 顶点小方块。
p.setBrush(accent);
for (const QPoint& pt : pts_) p.drawRect(pt.x() - 3, pt.y() - 3, 6, 6);
}
private:
QVector<QPoint> pts_;
QPoint cursor_;
int markType_ = 0;
bool drawing_ = false;
};
ContourDrawTool::ContourDrawTool(QwtPlot* plot, int xAxis, int yAxis, QObject* parent)
: QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) {
if (plot_ && plot_->canvas()) {
overlay_ = new ContourDrawOverlay(plot_->canvas());
overlay_->setGeometry(plot_->canvas()->rect());
overlay_->hide();
// 后装于 LivePanner/hover → 绘制期优先消费事件(含 canvas resize 同步 overlay
plot_->canvas()->installEventFilter(this);
}
}
void ContourDrawTool::begin(int markType) {
if (!plot_ || !plot_->canvas()) return;
active_ = true;
markType_ = markType;
dataPts_.clear();
lastCursor_ = QPoint();
plot_->canvas()->setCursor(Qt::CrossCursor);
savedFocus_ = plot_->canvas()->focusPolicy(); // 记录原焦点策略,退出时还原
savedFocusValid_ = true;
plot_->canvas()->setFocusPolicy(Qt::StrongFocus);
plot_->canvas()->setFocus(); // 让 canvas 接收回车/Esc 键
if (overlay_) {
overlay_->setGeometry(plot_->canvas()->rect());
overlay_->raise();
overlay_->show();
refreshOverlay();
}
const QString hint = (markType == 2 || markType == 3)
? QStringLiteral("逐点单击采集双击或回车结束Esc 取消")
: QStringLiteral("单击落点Esc 取消");
QToolTip::showText(plot_->canvas()->mapToGlobal(QPoint(8, 8)), hint, plot_->canvas());
}
void ContourDrawTool::restoreCanvas() {
if (plot_ && plot_->canvas()) {
plot_->canvas()->unsetCursor();
if (savedFocusValid_) plot_->canvas()->setFocusPolicy(savedFocus_); // 还原焦点策略
}
savedFocusValid_ = false;
if (overlay_) {
overlay_->setState({}, QPoint(), 0, false);
overlay_->hide();
}
}
void ContourDrawTool::cancel() {
active_ = false;
dataPts_.clear();
restoreCanvas();
}
QPointF ContourDrawTool::toData(const QPoint& canvasPos) const {
if (!plot_) return {};
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
return QPointF(xMap.invTransform(canvasPos.x()), yMap.invTransform(canvasPos.y()));
}
void ContourDrawTool::addVertex(const QPoint& canvasPos) {
dataPts_.push_back(toData(canvasPos));
// 点/文字:单点即完成。
if (markType_ == 1 || markType_ == 4) { finish(); return; }
refreshOverlay();
}
void ContourDrawTool::finish() {
if (!active_) return;
auto pts = normalizeDrawnPoints(dataPts_, markType_);
if (static_cast<int>(pts.size()) < minPointsForMarkType(markType_)) {
// 点数不足(如线只点了 1 个就双击):保持绘制态,等用户补点。
return;
}
active_ = false;
restoreCanvas();
if (onComplete_) onComplete_(pts);
}
void ContourDrawTool::refreshOverlay() {
if (!overlay_ || !plot_) return;
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
QVector<QPoint> px;
px.reserve(static_cast<int>(dataPts_.size()));
for (const QPointF& d : dataPts_)
px.push_back(QPoint(static_cast<int>(xMap.transform(d.x())),
static_cast<int>(yMap.transform(d.y()))));
overlay_->setState(px, lastCursor_, markType_, active_);
}
bool ContourDrawTool::eventFilter(QObject* obj, QEvent* ev) {
if (!plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
// 始终同步 overlay 尺寸(即便未激活,保证下次激活时贴合)。
if (ev->type() == QEvent::Resize && overlay_) {
overlay_->setGeometry(plot_->canvas()->rect());
return QObject::eventFilter(obj, ev);
}
if (!active_) return QObject::eventFilter(obj, ev);
switch (ev->type()) {
case QEvent::MouseButtonDblClick: {
auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() == Qt::LeftButton) {
// Qt 双击序列Press→Release→DblClick。前一个 Press 已 addVertex 落了一个与
// 双击位置重合的伪顶点,结束前移除它,避免线/面末尾出现重复点。
if (!dataPts_.empty()) dataPts_.pop_back();
finish();
return true;
}
break;
}
case QEvent::MouseButtonPress: {
auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() == Qt::LeftButton) {
// 双击的第一下会先来一个 Press线/面靠 DblClick 结束,这里只加点。
addVertex(me->pos());
return true;
}
if (me->button() == Qt::RightButton) { // 右键取消
const bool wasActive = active_;
cancel();
if (wasActive && onCancel_) onCancel_();
return true;
}
break;
}
case QEvent::MouseMove: {
auto* me = static_cast<QMouseEvent*>(ev);
lastCursor_ = me->pos();
if (!dataPts_.empty()) refreshOverlay();
return true; // 绘制期消费移动(不弹 hover
}
case QEvent::KeyPress: {
auto* ke = static_cast<QKeyEvent*>(ev);
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) { finish(); return true; }
if (ke->key() == Qt::Key_Escape) {
cancel();
if (onCancel_) onCancel_();
return true;
}
break;
}
default:
break;
}
return QObject::eventFilter(obj, ev);
}
} // namespace geopro::app

View File

@ -0,0 +1,66 @@
#pragma once
#include <functional>
#include <vector>
#include <QObject>
#include <QPoint>
#include <QPointF>
#include <QPointer>
#include <Qt> // Qt::FocusPolicy
class QwtPlot;
namespace geopro::app {
class ContourDrawOverlay;
// I9 图上绘形工具:开启后在等值面图上用鼠标采集几何,实时预览,完成后回调数据坐标点。
// 复刻原版 contour overlay 绘制交互先弹窗选类型→再图上画→drawingComplete
// markType 1 点 / 4 文字:单击落点立即完成;
// markType 2 线逐点单击双击或回车结束≥2 点);
// markType 3 面逐点单击双击或回车闭合≥3 点)。
// Esc 取消。绘制时事件优先于 LivePanner/hover 消费(开启期间禁用平移)。
// 不拥有 plot外部持有QPointer 守护overlay 父=canvas 随之析构。
class ContourDrawTool : public QObject {
Q_OBJECT
public:
// xAxis/yAxis等值面所在轴GridDataChartView 为 xBottom/yLeft
ContourDrawTool(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr);
// 完成回调:参数为数据坐标点序列(已按类型归一化:点/文字 1 点线≥2面≥3
void setOnComplete(std::function<void(const std::vector<QPointF>&)> cb) { onComplete_ = std::move(cb); }
// 取消回调Esc / 右键 / 外部 cancel调用方据此恢复 UI如重新开放工具条
void setOnCancel(std::function<void()> cb) { onCancel_ = std::move(cb); }
// 开始绘制指定标注类型("1".."4" 对应整数)。会重置已采集点并显示提示。
void begin(int markType);
// 外部强制取消(不触发 onCancel_
void cancel();
bool isActive() const { return active_; }
protected:
bool eventFilter(QObject* obj, QEvent* ev) override;
private:
QPointF toData(const QPoint& canvasPos) const; // 像素 → 数据坐标
void addVertex(const QPoint& canvasPos);
void finish(); // 校验最少点数 → onComplete_
void refreshOverlay(); // 把当前已落点(数据坐标)映射回像素喂给 overlay 预览
QPointer<QwtPlot> plot_;
int xAxis_;
int yAxis_;
std::function<void(const std::vector<QPointF>&)> onComplete_;
std::function<void()> onCancel_;
ContourDrawOverlay* overlay_ = nullptr; // 父=canvas
bool active_ = false;
int markType_ = 0;
std::vector<QPointF> dataPts_; // 已采集点(数据坐标)
QPoint lastCursor_; // 当前光标(橡皮筋预览到此)
Qt::FocusPolicy savedFocus_ = Qt::NoFocus; // begin 前 canvas 焦点策略(退出还原)
bool savedFocusValid_ = false;
void restoreCanvas(); // 退出绘制态:还原光标/焦点策略 + 隐藏 overlay
};
} // namespace geopro::app

View File

@ -5,14 +5,19 @@
#include <QCursor> #include <QCursor>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QJsonArray>
#include <QMessageBox>
#include <QPainter> #include <QPainter>
#include <QPointer>
#include <QPushButton> #include <QPushButton>
#include <QTableView> #include <QTableView>
#include <QToolTip> #include <QToolTip>
#include <QVBoxLayout> #include <QVBoxLayout>
#include "panels/chart/InversionFormDialog.hpp" #include "panels/chart/InversionFormDialog.hpp"
#include "panels/chart/ScatterDataOps.hpp" // toggledDisplayStatus
#include "panels/chart/TablePager.hpp" #include "panels/chart/TablePager.hpp"
#include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
@ -70,6 +75,38 @@ geopro::core::TableColumnKind TablePayloadModel::columnKind(int column) const {
return payload_.columns[static_cast<size_t>(column)].kind; return payload_.columns[static_cast<size_t>(column)].kind;
} }
int TablePayloadModel::toggleColumn() const {
for (size_t i = 0; i < payload_.columns.size(); ++i)
if (payload_.columns[i].kind == geopro::core::TableColumnKind::Toggle)
return static_cast<int>(i);
return -1;
}
QString TablePayloadModel::rowId(int row) const {
if (row < 0 || row >= static_cast<int>(payload_.rowIds.size())) return {};
return payload_.rowIds[static_cast<size_t>(row)];
}
int TablePayloadModel::rowDisplayStatus(int row) const {
const int col = toggleColumn();
if (col < 0 || row < 0 || row >= static_cast<int>(payload_.rows.size())) return 0;
const auto& cells = payload_.rows[static_cast<size_t>(row)];
if (col >= static_cast<int>(cells.size())) return 0;
// Toggle 单元 "1"=ON/可见 → displayStatus 0否则隐藏 → 1。
return cells[static_cast<size_t>(col)] == QLatin1String("1") ? 0 : 1;
}
void TablePayloadModel::setRowDisplayStatus(int row, int status) {
const int col = toggleColumn();
if (col < 0 || row < 0 || row >= static_cast<int>(payload_.rows.size())) return;
auto& cells = payload_.rows[static_cast<size_t>(row)];
if (col >= static_cast<int>(cells.size())) return;
// status 0=显示 → 单元 "1"(ON)status 1=隐藏 → "0"(OFF)。
cells[static_cast<size_t>(col)] = (status == 0) ? QStringLiteral("1") : QStringLiteral("0");
const QModelIndex idx = index(row, col);
emit dataChanged(idx, idx, {Qt::DisplayRole});
}
ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent) ToggleSwitchDelegate::ToggleSwitchDelegate(const TablePayloadModel* model, QObject* parent)
: QStyledItemDelegate(parent), model_(model) {} : QStyledItemDelegate(parent), model_(model) {}
@ -141,6 +178,8 @@ DataTableView::DataTableView(QWidget* parent) : QWidget(parent) {
// Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。 // Toggle 列委托:把“隐藏/显示”列画成蓝色药丸开关。
table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_)); table_->setItemDelegate(new ToggleSwitchDelegate(model_, table_));
// M2点击 Toggle 列(仅 measurement 可交互)→ 行级显隐切换。
connect(table_, &QTableView::clicked, this, &DataTableView::onCellClicked);
lay->addWidget(table_); lay->addWidget(table_);
@ -227,4 +266,42 @@ void DataTableView::onFunctionButton(const QString& code) {
dlg.exec(); // 提交反馈由对话框内部处理;列表无需刷新(原版亦仅 Message.success 提示)。 dlg.exec(); // 提交反馈由对话框内部处理;列表无需刷新(原版亦仅 Message.success 提示)。
} }
void DataTableView::onCellClicked(const QModelIndex& index) {
// M2 行级显隐:仅 measurement 列表toggleInteractive的 Toggle 列响应点击;其余视图无操作。
if (!index.isValid() || !model_->isToggleInteractive()) return;
if (model_->columnKind(index.column()) != geopro::core::TableColumnKind::Toggle) return;
const int row = index.row();
const QString id = model_->rowId(row);
const int cur = model_->rowDisplayStatus(row); // 0=显示 1=隐藏
const int next = toggledDisplayStatus(cur); // 取反(对照原版 record.displayStatus ? 0 : 1
// popconfirm 文案:当前显示(0)→将隐藏;当前隐藏→将显示(对照原版 scatterPopHide/scatterPopShow
const QString text = (cur == 0) ? QStringLiteral("该操作会隐藏该散点,确认?")
: QStringLiteral("该操作会显示该散点,确认?");
if (QMessageBox::question(this, QStringLiteral("提示"), text,
QMessageBox::Ok | QMessageBox::Cancel) != QMessageBox::Ok)
return;
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty() || id.isEmpty()) {
// 无仓储/无 dsId/无行 id → 仅本地切换(退化,不持久化)。
model_->setRowDisplayStatus(row, next);
return;
}
QJsonArray ids;
ids.append(id);
QPointer<DataTableView> self(this);
cmdRepo_->saveDisplayStatus(dsId, ids, next, [self, row, next](bool ok, QString msg) {
if (!self) return;
if (!ok) {
QMessageBox::warning(self, QStringLiteral("提示"),
msg.isEmpty() ? QStringLiteral("操作失败") : msg);
return;
}
self->model_->setRowDisplayStatus(row, next); // 持久化成功后更新该行状态
});
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -33,7 +33,17 @@ public:
// 列渲染种类(供委托判断是否画开关)。越界返回 Text。 // 列渲染种类(供委托判断是否画开关)。越界返回 Text。
geopro::core::TableColumnKind columnKind(int column) const; geopro::core::TableColumnKind columnKind(int column) const;
// M2该 Toggle 列是否可交互(仅 measurement 载荷为 true
bool isToggleInteractive() const { return payload_.toggleInteractive; }
// M2取行点 id越界/无 id → 空串)。
QString rowId(int row) const;
// M2取行当前显隐状态0=显示 1=隐藏;据 Toggle 单元 "1"=ON/可见反推)。
int rowDisplayStatus(int row) const;
// M2把某行 Toggle 单元就地设为指定状态status 0=显示 → "1"/ON持久化成功后调用
void setRowDisplayStatus(int row, int status);
private: private:
int toggleColumn() const; // Toggle 列下标(无则 -1
geopro::core::TablePayload payload_; geopro::core::TablePayload payload_;
}; };
@ -81,6 +91,7 @@ signals:
private: private:
void rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons); void rebuildToolbar(const std::vector<geopro::core::TableFunctionButton>& buttons);
void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效) void onFunctionButton(const QString& code); // 功能按钮路由(仅 inversion 起效)
void onCellClicked(const QModelIndex& index); // M2 行级显隐切换(仅 measurement Toggle 列)
QWidget* toolbar_; // 顶部功能按钮行容器functionButtons 空时隐藏) QWidget* toolbar_; // 顶部功能按钮行容器functionButtons 空时隐藏)
QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填) QHBoxLayout* toolbarLay_; // 功能按钮布局(重建时清空重填)

View File

@ -72,8 +72,8 @@ ExceptionDialog::ExceptionDialog(geopro::data::IDatasetCommandRepository* repo,
cardLay->addLayout(form); cardLay->addLayout(form);
root->addWidget(card); root->addWidget(card);
// 坐标x/y 多行),下方加/减行按钮 // 坐标兜底表x/y 多行):留空 → 确定后在图上绘形采集(主路径);手填 → 直接提交
root->addWidget(new QLabel(QStringLiteral("坐标xy"), this)); root->addWidget(new QLabel(QStringLiteral("坐标xy,留空则在图上绘制"), this));
coordTable_ = new QTableWidget(0, 2, this); coordTable_ = new QTableWidget(0, 2, this);
coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")}); coordTable_->setHorizontalHeaderLabels({QStringLiteral("x"), QStringLiteral("y")});
coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); coordTable_->horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch);
@ -115,10 +115,35 @@ QString ExceptionDialog::markTypeValue() const {
return markTypeCombo_->currentData().toString(); return markTypeCombo_->currentData().toString();
} }
QString ExceptionDialog::exceptionTypeId() const {
return exceptionTypeCombo_->currentData().toString();
}
QString ExceptionDialog::exceptionName() const {
return nameEdit_->text().trimmed();
}
QString ExceptionDialog::exceptionRemark() const {
return remarkEdit_->toPlainText();
}
QJsonArray ExceptionDialog::manualCoordinates() const {
QJsonArray coords;
for (int r = 0; r < coordTable_->rowCount(); ++r) {
auto* ix = coordTable_->item(r, 0);
auto* iy = coordTable_->item(r, 1);
if (!ix || !iy || ix->text().trimmed().isEmpty() || iy->text().trimmed().isEmpty()) continue;
bool okx = false, oky = false;
const double x = ix->text().toDouble(&okx);
const double y = iy->text().toDouble(&oky);
if (!okx || !oky) continue;
coords.append(QJsonObject{{QStringLiteral("x"), x}, {QStringLiteral("y"), y}});
}
return coords;
}
void ExceptionDialog::onTypeChanged() { void ExceptionDialog::onTypeChanged() {
// 调整坐标表行数到该形态最少点数(不足则补行;已多则保留)。 // 主路径为图上绘形 → 坐标表默认留空(不自动补行),仅刷新异常类型列表。
const int need = minPoints(markTypeValue());
while (coordTable_->rowCount() < need) coordTable_->insertRow(coordTable_->rowCount());
loadExceptionTypes(); loadExceptionTypes();
} }
@ -169,18 +194,11 @@ void ExceptionDialog::onConfirm() {
QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型")); QMessageBox::warning(this, windowTitle(), QStringLiteral("请选择异常类型"));
return; return;
} }
// 收集坐标(跳过空行)。 // 主路径:坐标表留空 → accept(),由调用方在图上绘形采集坐标后 newException。
QJsonArray coords; const QJsonArray coords = manualCoordinates();
for (int r = 0; r < coordTable_->rowCount(); ++r) { if (coords.isEmpty()) { accept(); return; }
auto* ix = coordTable_->item(r, 0);
auto* iy = coordTable_->item(r, 1); // 兜底路径:用户手填了坐标 → 校验点数后直接弹窗内提交。
if (!ix || !iy || ix->text().trimmed().isEmpty() || iy->text().trimmed().isEmpty()) continue;
bool okx = false, oky = false;
const double x = ix->text().toDouble(&okx);
const double y = iy->text().toDouble(&oky);
if (!okx || !oky) continue;
coords.append(QJsonObject{{QStringLiteral("x"), x}, {QStringLiteral("y"), y}});
}
if (coords.size() < minPoints(markTypeValue())) { if (coords.size() < minPoints(markTypeValue())) {
QMessageBox::warning(this, windowTitle(), QMessageBox::warning(this, windowTitle(),
QStringLiteral("坐标点数不足(点/文字≥1线≥2面≥3")); QStringLiteral("坐标点数不足(点/文字≥1线≥2面≥3"));

View File

@ -2,6 +2,7 @@
#include <functional> #include <functional>
#include <QDialog> #include <QDialog>
#include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QString> #include <QString>
@ -17,24 +18,31 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 异常创建对话框I9复刻原版 exceptionDialog + contourPage 保存链路 // 异常创建对话框I9复刻原版 exceptionDialog 时序
// 标注类型(点/线/面/文字 → remarkSourceType "1"/"2"/"3"/"4") + 异常类型(listExceptionTypes) // 先弹窗选 标注类型(点/线/面/文字 → remarkSourceType "1".."4") + 异常类型(listExceptionTypes)
// + 名称(getExceptionName 建议) + 备注 + 坐标表 // + 名称(getExceptionName 建议) + 备注;确认后由调用方在图上交互绘形采集坐标,再 newException
// 确认 → newException(body),成功 accept(),调用方随后 reloadGrid。 // 时序对照原版 contourPage/exceptionDialog弹窗仅收元信息不收坐标accept() 后调用方读
// 说明:原版「图上交互式绘制几何」在 Qwt 成本高本实现以坐标表x/y 多行)采集 location // markTypeValue()/exceptionTypeId()/name()/remark() 启动 ContourDrawTool 绘形 → 完成提交。
// 覆盖点(1 行)/线(≥2)/面(≥3)/文字(1) 全形态打通完整创建链路on-chart 拖拽绘制为后置项 // 兜底:坐标表仍保留(无法图上绘形时可手填),有有效坐标行则直接走旧版「弹窗内提交」链路
class ExceptionDialog : public QDialog { class ExceptionDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId, ExceptionDialog(geopro::data::IDatasetCommandRepository* repo, QString projectId,
QString remarkSourceId, QWidget* parent = nullptr); QString remarkSourceId, QWidget* parent = nullptr);
// accept() 后供调用方读取(驱动图上绘形 + newException
QString markTypeValue() const; // 标注类型 "1".."4"remarkSourceType
QString exceptionTypeId() const; // 异常类型 id
QString exceptionName() const; // 名称
QString exceptionRemark() const; // 备注
// 兜底坐标(用户在表里手填的有效行);空 = 走图上绘形主路径。
QJsonArray manualCoordinates() const;
private: private:
void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行 void onTypeChanged(); // 标注类型变 → 重拉异常类型列表 + 调整坐标表最少行
void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType) void loadExceptionTypes(); // listExceptionTypes(projectId, remarkSourceType)
void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议 void suggestName(); // getExceptionName(exceptionTypeId, remarkSourceId) → 名称建议
void onConfirm(); // 校验 → newException void onConfirm(); // 校验 → 有手填坐标则直接 newException否则 accept() 交给绘形
QString markTypeValue() const; // 当前标注类型字符串("1".."4")
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString projectId_; QString projectId_;

View File

@ -8,7 +8,9 @@
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
#include <QMessageBox> #include <QMessageBox>
#include <QPointF>
#include <QPointer> #include <QPointer>
#include <vector>
#include <QSignalBlocker> #include <QSignalBlocker>
#include <QLabel> #include <QLabel>
#include <QSlider> #include <QSlider>
@ -33,6 +35,7 @@
#include "panels/chart/AutoAnnotationDialog.hpp" #include "panels/chart/AutoAnnotationDialog.hpp"
#include "panels/chart/ColorBarWidget.hpp" #include "panels/chart/ColorBarWidget.hpp"
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
#include "panels/chart/ContourDrawTool.hpp"
#include "panels/chart/ContourHoverTip.hpp" #include "panels/chart/ContourHoverTip.hpp"
#include "panels/chart/ContourPlotItem.hpp" #include "panels/chart/ContourPlotItem.hpp"
#include "panels/chart/ExceptionDetailDialog.hpp" #include "panels/chart/ExceptionDetailDialog.hpp"
@ -217,13 +220,16 @@ GridDataChartView::GridDataChartView(QWidget* parent) : QWidget(parent) {
// 描述保存I14 // 描述保存I14
connect(descriptionPanel_, &DescriptionPanel::saveRequested, this, connect(descriptionPanel_, &DescriptionPanel::saveRequested, this,
[this](const QString& t) { saveDescription(t); }); [this]() { saveDescription(); });
// I7 显示等值线提示信息hover tooltip 显隐(本地,挂画布事件过滤器)。 // I7 显示等值线提示信息hover tooltip 显隐(本地,挂画布事件过滤器)。
contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this); contourTip_ = new ContourHoverTip(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
connect(chkContourTip, &QCheckBox::toggled, this, connect(chkContourTip, &QCheckBox::toggled, this,
[this](bool on) { if (contourTip_) contourTip_->setEnabled(on); }); [this](bool on) { if (contourTip_) contourTip_->setEnabled(on); });
// I9 图上绘形工具(后装于 LivePanner/hover绘制期优先消费事件。默认空闲。
drawTool_ = new ContourDrawTool(plot_, QwtPlot::xBottom, QwtPlot::yLeft, this);
// 主题配色:当前主题套一次 + 监听切换热更新。 // 主题配色:当前主题套一次 + 监听切换热更新。
applyChartPlotTheme(plot_); applyChartPlotTheme(plot_);
QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_, QObject::connect(&ThemeManager::instance(), &ThemeManager::changed, plot_,
@ -374,9 +380,17 @@ void GridDataChartView::openWhitening() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
// tmObjectId白化模板列表用客户端视图未透传 structParentId按原版兜底空串。 // tmObjectId白化模板列表用= 当前数据集的 structParentId对照原版 dsFileRow.structParentId
WhiteningDialog dlg(cmdRepo_, dsId, projectId, QString(), this); // 客户端 open 链路未透传该字段 → 方案 A懒拉 getDsObjectDetail(dsId) 取 structParentId
if (dlg.exec() == QDialog::Accepted) reloadGrid(); // 再打开白化对话框。即便取不到(空串)也照常打开(与原版兜底一致,仅模板列表为空)。
QPointer<GridDataChartView> self(this);
cmdRepo_->getDsObjectDetail(dsId, [self, dsId, projectId](bool ok, QJsonObject data, QString) {
if (!self) return;
const QString tmObjectId =
ok ? data.value(QStringLiteral("structParentId")).toString() : QString();
WhiteningDialog dlg(self->cmdRepo_, dsId, projectId, tmObjectId, self);
if (dlg.exec() == QDialog::Accepted) self->reloadGrid();
});
} }
void GridDataChartView::openFilter() { void GridDataChartView::openFilter() {
@ -406,9 +420,57 @@ void GridDataChartView::openExceptionDialog() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString(); const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; }
// remarkSourceId = dsObjectId(异常挂当前等值面数据集)。 // 时序复刻原版:先弹窗选 标注类型/异常类型/名称/备注(remarkSourceId = dsObjectId
ExceptionDialog dlg(cmdRepo_, projectId, dsId, this); ExceptionDialog dlg(cmdRepo_, projectId, dsId, this);
if (dlg.exec() == QDialog::Accepted) reloadGrid(); if (dlg.exec() != QDialog::Accepted) return;
// 兜底:用户手填了坐标 → 对话框内部已提交,仅重载。
if (!dlg.manualCoordinates().isEmpty()) { reloadGrid(); return; }
// 主路径:弹窗后在图上交互绘形 → 完成回调组装 newException对照原版 startDraw*→
// drawingComplete→newExceptionInProfileInversion
const QString markType = dlg.markTypeValue();
const QString typeId = dlg.exceptionTypeId();
const QString name = dlg.exceptionName();
const QString remark = dlg.exceptionRemark();
if (!drawTool_) return;
QPointer<GridDataChartView> self(this);
drawTool_->setOnComplete([self, markType, typeId, name, remark](const std::vector<QPointF>& pts) {
if (!self) return;
QJsonArray coords;
for (const QPointF& p : pts)
coords.append(QJsonObject{{QStringLiteral("x"), p.x()}, {QStringLiteral("y"), p.y()}});
self->submitDrawnException(markType, typeId, name, remark, coords);
});
drawTool_->setOnCancel([] {}); // 取消绘形:无操作(不提交)
drawTool_->begin(markType.toInt());
}
void GridDataChartView::submitDrawnException(const QString& markType, const QString& typeId,
const QString& name, const QString& remark,
const QJsonArray& coords) {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
const QString projectId = projectIdGetter_ ? projectIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) return;
QJsonObject body{
{QStringLiteral("exceptionName"), name},
{QStringLiteral("exceptionTypeId"), typeId},
{QStringLiteral("remark"), remark},
{QStringLiteral("remarkSourceType"), markType}, // 几何形态字符串 "1".."4"
{QStringLiteral("remarkSourceId"), dsId}, // = dsObjectId
{QStringLiteral("projectId"), projectId},
{QStringLiteral("location"), QJsonObject{{QStringLiteral("coordinate"), coords}}},
};
QPointer<GridDataChartView> self(this);
cmdRepo_->newException(body, [self](bool ok, QString msg) {
if (!self) return;
if (!ok) {
QMessageBox::warning(self, QStringLiteral("新建异常"),
msg.isEmpty() ? QStringLiteral("创建失败") : msg);
return;
}
self->reloadGrid(); // 成功后重载(列表 + 图层同步)
});
} }
void GridDataChartView::openAutoAnnotation() { void GridDataChartView::openAutoAnnotation() {
@ -472,32 +534,33 @@ void GridDataChartView::loadDescription() {
QPointer<GridDataChartView> self(this); QPointer<GridDataChartView> self(this);
cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) { cmdRepo_->getDsObjectDetail(dsId, [self](bool ok, QJsonObject data, QString) {
if (!self || !ok) return; if (!self || !ok) return;
// 原版从 attachedParameters.deltaContent 取 Quill DeltaQt 退化为纯文本: // 原版从 attachedParameters.deltaContent 取 Quill Delta 回填编辑器quill.setContents
// 优先 description 字段,否则拼接 delta ops 的 insert 文本。 // 客户端用 QuillDelta::deltaToDocument 还原富文本;无 deltaContent 时回退 description 纯文本。
QString text = data.value(QStringLiteral("description")).toString(); const QJsonArray ops = data.value(QStringLiteral("attachedParameters"))
if (text.isEmpty()) { .toObject()
const QJsonArray ops = data.value(QStringLiteral("attachedParameters")) .value(QStringLiteral("deltaContent"))
.toObject() .toArray();
.value(QStringLiteral("deltaContent")) if (!ops.isEmpty())
.toArray(); self->descriptionPanel_->setDelta(ops);
for (const QJsonValue& op : ops) else
text += op.toObject().value(QStringLiteral("insert")).toString(); self->descriptionPanel_->setPlainText(
} data.value(QStringLiteral("description")).toString());
self->descriptionPanel_->setText(text);
}); });
} }
void GridDataChartView::saveDescription(const QString& text) { void GridDataChartView::saveDescription() {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(nullptr); return; } if (!cmdRepo_ || dsId.isEmpty() || !descriptionPanel_) {
// attachedParameters.deltaContent以最简单 op 包纯文本reload 时可还原为纯文本)。 showNotImplemented(nullptr);
QJsonArray ops; return;
if (!text.isEmpty()) ops.append(QJsonObject{{QStringLiteral("insert"), text}}); }
// 与原版 saveQuillEditorContent 对齐:
// description = 纯文本quill.getText()deltaContent = Quill Delta opsquill.getContents().ops
QJsonObject body{ QJsonObject body{
{QStringLiteral("dsObjectId"), dsId}, {QStringLiteral("dsObjectId"), dsId},
{QStringLiteral("description"), text}, {QStringLiteral("description"), descriptionPanel_->plainText()},
{QStringLiteral("attachedParameters"), {QStringLiteral("attachedParameters"),
QJsonObject{{QStringLiteral("deltaContent"), ops}}}, QJsonObject{{QStringLiteral("deltaContent"), descriptionPanel_->delta()}}},
}; };
QPointer<GridDataChartView> self(this); QPointer<GridDataChartView> self(this);
cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) { cmdRepo_->updateDsObject(body, [self](bool ok, QString msg) {

View File

@ -5,6 +5,8 @@
#include <QString> #include <QString>
#include <QWidget> #include <QWidget>
class QJsonArray;
#include "model/Anomaly.hpp" #include "model/Anomaly.hpp"
#include "model/ColorScale.hpp" #include "model/ColorScale.hpp"
#include "model/Field.hpp" #include "model/Field.hpp"
@ -32,6 +34,7 @@ class ColorBarWidget;
class ColorMapService; class ColorMapService;
class ContourPlotItem; class ContourPlotItem;
class ContourHoverTip; class ContourHoverTip;
class ContourDrawTool;
// 网格数据图表视图:工具条 + QwtPlot白底 + 真实比尺 + 实时平移/滚轮缩放x 轴在底部) // 网格数据图表视图:工具条 + QwtPlot白底 + 真实比尺 + 实时平移/滚轮缩放x 轴在底部)
// + 独立色阶条 + 底部双页签(异常列表/描述)。 // + 独立色阶条 + 底部双页签(异常列表/描述)。
@ -72,13 +75,16 @@ private:
void applySimplify(); // I8把当前滑块容差透传给 ContourPlotItem 并重绘 void applySimplify(); // I8把当前滑块容差透传给 ContourPlotItem 并重绘
void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId void showNotImplemented(QWidget* anchor); // 占位提示(无仓储/无 dsId
void openExceptionDialog(); // I9 异常创建 void openExceptionDialog(); // I9 异常创建(弹窗选类型 → 图上绘形 → 提交)
// I9 图上绘形完成:组装 body 提交 newException成功 reloadGrid
void submitDrawnException(const QString& markType, const QString& typeId, const QString& name,
const QString& remark, const QJsonArray& coords);
void openAutoAnnotation(); // I13 自动标注 void openAutoAnnotation(); // I13 自动标注
void deleteAnomaly(int index); // I10 异常删除 void deleteAnomaly(int index); // I10 异常删除
void showAnomalyDetail(int index); // I11 异常详情/编辑 void showAnomalyDetail(int index); // I11 异常详情/编辑
void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放) void locateAnomaly(int index); // I12 异常定位(高亮 + 缩放)
void loadDescription(); // I14 进入时回填描述 void loadDescription(); // I14 进入时回填描述
void saveDescription(const QString& text); // I14 保存描述 void saveDescription(); // I14 保存描述(从面板取 Delta + 纯文本)
QwtPlot* plot_ = nullptr; QwtPlot* plot_ = nullptr;
QwtPlotRescaler* rescaler_ = nullptr; QwtPlotRescaler* rescaler_ = nullptr;
@ -90,6 +96,7 @@ private:
QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步) QCheckBox* chkShowLabels_ = nullptr; // 工具条「显示等值线标注」(线形⚙ 改标注显隐后同步)
QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms QTimer* simplifyDebounce_ = nullptr; // I8 简化容差防抖(~300ms
ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示hover ContourHoverTip* contourTip_ = nullptr; // I7 等值线提示hover
ContourDrawTool* drawTool_ = nullptr; // I9 图上绘形工具QObjectthis 持有)
// 渲染状态 // 渲染状态
ColorMapService* colorSvc_ = nullptr; // heapsetGridData 重建 ColorMapService* colorSvc_ = nullptr; // heapsetGridData 重建

View File

@ -8,6 +8,7 @@
#include "panels/chart/ScatterDataOps.hpp" #include "panels/chart/ScatterDataOps.hpp"
#include "panels/chart/ScatterFilterDialog.hpp" #include "panels/chart/ScatterFilterDialog.hpp"
#include "panels/chart/ScatterHoverTip.hpp" #include "panels/chart/ScatterHoverTip.hpp"
#include "panels/chart/ScatterMarqueePicker.hpp"
#include "panels/chart/ScatterPlotItem.hpp" #include "panels/chart/ScatterPlotItem.hpp"
#include <utility> #include <utility>
@ -141,6 +142,11 @@ RawDataChartView::RawDataChartView(QWidget* parent) : QWidget(parent) {
hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this); hoverTip_ = new ScatterHoverTip(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
hoverTip_->setField(&data_.scatter); hoverTip_->setField(&data_.scatter);
// M14 框选拾取器(最后装 → 事件链最先收到active 时优先消费拖拽,禁用平移)。默认关闭。
marquee_ = new ScatterMarqueePicker(plot_, QwtPlot::xTop, QwtPlot::yLeft, this);
marquee_->setField(&data_.scatter);
marquee_->setOnSelected([this](const std::vector<int>& idx) { onMarqueeSelected(idx); });
// 允许随停靠面板自由收缩(不强制最小宽度)。 // 允许随停靠面板自由收缩(不强制最小宽度)。
plot_->setMinimumSize(0, 0); plot_->setMinimumSize(0, 0);
@ -443,31 +449,55 @@ void RawDataChartView::onShowHide(bool hide) {
QMessageBox::Ok | QMessageBox::Cancel); QMessageBox::Ok | QMessageBox::Cancel);
if (ans != QMessageBox::Ok) return; if (ans != QMessageBox::Ok) return;
// 本地切换:显示/隐藏全部数据方块(电极保留)。 // 选区联动M14↔M1隐藏且有选区 → 只对选中点(原版 getSelectedPointIds
auto localToggle = [this, hide]() { // 其余(隐藏无选区 / 显示)维持全部(原版显示恒为全部隐藏点)。
const bool selective = hide && scatterItem_ && scatterItem_->hasSelection();
// 本地切换可见性。selective逐点改 displayStatus仅选中点隐藏否则整体显隐全部方块。
auto localToggle = [this, hide, selective]() {
if (!scatterItem_) return; if (!scatterItem_) return;
scatterItem_->setScatterVisible(!hide); if (selective) {
scatterItem_->setData(data_.scatter, colorSvc_); // 重读 displayStatus 逐点生效
scatterItem_->clearSelection();
} else {
for (int& s : data_.scatter.displayStatus) s = hide ? 1 : 0;
scatterItem_->setScatterVisible(!hide);
if (!hide) scatterItem_->setData(data_.scatter, colorSvc_); // 显示全部:清逐点隐藏
}
plot_->replot(); plot_->replot();
}; };
// 收集要持久化的点 idselective → 选中点 id否则隐藏取可见点 / 显示取隐藏点。
QJsonArray ids;
if (selective) {
for (const QString& id : scatterItem_->getSelectedIds()) ids.append(id);
// 先把选中点的 displayStatus 标为隐藏(本地,供 localToggle 重读生效)。
const auto sel = scatterItem_->getSelectedIds();
for (int i = 0; i < static_cast<int>(data_.scatter.id.size()); ++i) {
const QString id = QString::fromStdString(data_.scatter.id[i]);
if (!id.isEmpty() && sel.end() != std::find(sel.begin(), sel.end(), id))
data_.scatter.displayStatus[i] = 1;
}
} else {
ids = collectScatterIds(data_.scatter, hide);
}
// 无仓储/无 dsId → 仅本地切换(退化,不持久化)。 // 无仓储/无 dsId → 仅本地切换(退化,不持久化)。
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; } if (!cmdRepo_ || dsId.isEmpty()) { localToggle(); return; }
// 收集要持久化的点 id隐藏取可见点 / 显示取隐藏点status0=显示 1=隐藏。
const QJsonArray ids = collectScatterIds(data_.scatter, hide);
const int status = hide ? 1 : 0; const int status = hide ? 1 : 0;
QPointer<RawDataChartView> self(this); QPointer<RawDataChartView> self(this);
cmdRepo_->saveDisplayStatus(dsId, ids, status, [self, hide, localToggle](bool ok, QString msg) { cmdRepo_->saveDisplayStatus(dsId, ids, status, [self, hide, selective, localToggle](bool ok, QString msg) {
if (!self) return; if (!self) return;
if (!ok) { if (!ok) {
QMessageBox::warning(self, QStringLiteral("提示"), QMessageBox::warning(self, QStringLiteral("提示"),
msg.isEmpty() ? QStringLiteral("操作失败") : msg); msg.isEmpty() ? QStringLiteral("操作失败") : msg);
return; return;
} }
// 持久化成功后同步本地 displayStatus 与方块可见性 // selective 时本地 displayStatus 已在请求前更新;非 selective 同步整体状态
const int newStatus = hide ? 1 : 0; if (!selective)
for (int& s : self->data_.scatter.displayStatus) s = newStatus; for (int& s : self->data_.scatter.displayStatus) s = hide ? 1 : 0;
localToggle(); localToggle();
}); });
} }
@ -475,7 +505,8 @@ void RawDataChartView::onShowHide(bool hide) {
void RawDataChartView::openFilterDialog(QWidget* anchor) { void RawDataChartView::openFilterDialog(QWidget* anchor) {
const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString(); const QString dsId = dsIdGetter_ ? dsIdGetter_() : QString();
if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; } if (!cmdRepo_ || dsId.isEmpty()) { showNotImplemented(anchor); return; }
ScatterFilterDialog dlg(cmdRepo_, dsId, currentVFieldCode(), this); // 传当前 V 值数组驱动分布直方图(与图上散点同源,反映当前值类型变换后的分布)。
ScatterFilterDialog dlg(cmdRepo_, dsId, currentVFieldCode(), data_.scatter.v, this);
dlg.exec(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。 dlg.exec(); // 成功/失败由对话框内部反馈(生成过滤数据集为后端动作)。
} }
@ -648,6 +679,23 @@ void RawDataChartView::showPointInfoAt(const QPoint& canvasPos) {
infoPanel_->raise(); infoPanel_->raise();
} }
void RawDataChartView::toggleMarqueeMode(bool on) {
marqueeMode_ = on;
if (marquee_) marquee_->setActive(on);
if (!on && scatterItem_) {
// 退出框选:清选区高亮(与原版 exitSelectMode clearSelection 一致)。
scatterItem_->clearSelection();
plot_->replot();
}
}
void RawDataChartView::onMarqueeSelected(const std::vector<int>& indices) {
// 框选完成:高亮框内散点(红框)。空框 → 清选区。
if (!scatterItem_) return;
scatterItem_->setSelectedIndices(indices);
plot_->replot();
}
bool RawDataChartView::eventFilter(QObject* obj, QEvent* ev) { bool RawDataChartView::eventFilter(QObject* obj, QEvent* ev) {
// 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。 // 仅信息模式 + 画布左键点击:找最近散点显示属性,不消费事件(保留平移链路)。
if (infoMode_ && plot_ && obj == plot_->canvas() && ev->type() == QEvent::MouseButtonPress) { if (infoMode_ && plot_ && obj == plot_->canvas() && ev->type() == QEvent::MouseButtonPress) {
@ -708,9 +756,9 @@ void RawDataChartView::buildMeasurementToolbar(const geopro::core::ScatterToolba
// [i] 信息:切换信息模式(点选散点看 A/B/M/N/DataRow/Pseu_Resis // [i] 信息:切换信息模式(点选散点看 A/B/M/N/DataRow/Pseu_Resis
btnInfo->setCheckable(true); btnInfo->setCheckable(true);
connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); }); connect(btnInfo, &QToolButton::toggled, this, [this](bool on) { toggleInfoMode(on); });
// [▣] 框选:本轮后置Qwt 橡皮筋框选 + 选区联动隐藏成本较高),保持占位提示 // [▣] 框选:可勾选 → 进入框选模式(橡皮筋选框内散点高亮;显示/隐藏改对选中点)
connect(btnMarquee, &QToolButton::clicked, this, btnMarquee->setCheckable(true);
[this, btnMarquee]() { showNotImplemented(btnMarquee); }); connect(btnMarquee, &QToolButton::toggled, this, [this](bool on) { toggleMarqueeMode(on); });
// 显示 / 隐藏popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换M1 // 显示 / 隐藏popconfirm 确认 → saveDisplayStatus 持久化 → 本地切换M1
auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar); auto* btnShow = new QPushButton(QStringLiteral("显示"), toolbar);
@ -816,6 +864,7 @@ void RawDataChartView::setData(const geopro::core::ScatterPayload& p) {
data_ = p; data_ = p;
baseV_ = data_.scatter.v; // 缓存原始 v线性M7 值类型变换从原值算,不累积误差 baseV_ = data_.scatter.v; // 缓存原始 v线性M7 值类型变换从原值算,不累积误差
if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖) if (hoverTip_) hoverTip_->setField(&data_.scatter); // 显式重绑(地址稳定,消除隐式依赖)
if (marquee_) marquee_->setField(&data_.scatter); // M14 框选拾取同源重绑
// measurement 载荷toolbar 非空):首次到来时建并替换工具条(视觉 1:1。反演留空 → 不动。 // measurement 载荷toolbar 非空):首次到来时建并替换工具条(视觉 1:1。反演留空 → 不动。
if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar); if (!p.toolbar.empty() && !measurementToolbar_) buildMeasurementToolbar(p.toolbar);

View File

@ -22,6 +22,7 @@ namespace geopro::app {
class ColorBarWidget; class ColorBarWidget;
class ScatterPlotItem; class ScatterPlotItem;
class ScatterHoverTip; class ScatterHoverTip;
class ScatterMarqueePicker;
// 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。 // 原数据图表视图:工具条 + QwtPlotx 轴顶部、Panner/Magnifier+ 独立色阶条。
class RawDataChartView : public QWidget, public IDetailView { class RawDataChartView : public QWidget, public IDetailView {
@ -76,6 +77,8 @@ private:
void exportDat(); // M12 导出 DAT void exportDat(); // M12 导出 DAT
void toggleInfoMode(bool on); // M13 [i] 信息模式开关 void toggleInfoMode(bool on); // M13 [i] 信息模式开关
void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性 void showPointInfoAt(const QPoint& canvasPos); // M13 点选显示属性
void toggleMarqueeMode(bool on); // M14 框选模式开关
void onMarqueeSelected(const std::vector<int>& indices); // M14 框选回调:高亮选中点
// 用 colorSvc_ 重绘当前散点M7/M8 本地变换/色阶变更后复用)。 // 用 colorSvc_ 重绘当前散点M7/M8 本地变换/色阶变更后复用)。
void redrawScatter(); void redrawScatter();
QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode QString currentVFieldCode() const; // 当前 V 值下拉 fieldCode
@ -106,6 +109,8 @@ private:
ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建 ColorMapService* colorSvc_ = nullptr; // heap由 setData 重建
ScatterPlotItem* scatterItem_ = nullptr; ScatterPlotItem* scatterItem_ = nullptr;
ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示QObjectthis 持有) ScatterHoverTip* hoverTip_ = nullptr; // 散点 hover 提示QObjectthis 持有)
ScatterMarqueePicker* marquee_ = nullptr; // M14 框选拾取器QObjectthis 持有)
bool marqueeMode_ = false; // M14 框选模式开关
// 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。 // 反演命令仓储 + dsId/projectId 取值回调(注入;空则反演按钮占位)。
geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr; geopro::data::IDatasetCommandRepository* cmdRepo_ = nullptr;

View File

@ -65,4 +65,26 @@ QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const Q
return body; return body;
} }
ScatterHistogram buildScatterHistogram(const std::vector<double>& v, double min, double max,
int binCount) {
ScatterHistogram h;
h.binMin = min;
h.binMax = max;
if (binCount <= 0 || !(max > min)) return h; // 退化:返回空 counts视图渲染空态
h.counts.assign(static_cast<std::size_t>(binCount), 0);
h.step = (max - min) / binCount;
for (double x : v) {
if (!std::isfinite(x) || x < min || x > max) continue; // 区间外/非有限 跳过
int idx = static_cast<int>((x - min) / h.step);
if (idx >= binCount) idx = binCount - 1; // 末箱右闭(恰等于 max 归入末箱)
if (idx < 0) idx = 0;
++h.counts[static_cast<std::size_t>(idx)];
}
return h;
}
int toggledDisplayStatus(int currentStatus) {
return currentStatus == 0 ? 1 : 0; // 0 显示 → 1 隐藏;其余 → 0 显示
}
} // namespace geopro::app } // namespace geopro::app

View File

@ -40,4 +40,23 @@ QJsonObject buildScatterFilterBody(const QString& dsObjectId, const QString& vFi
// {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。 // {dsId, operationType(1新增/0覆盖)},仅新增(operationType==1)才带 name。
QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name); QJsonObject buildSaveRawDataBody(const QString& dsId, int operationType, const QString& name);
// 直方图分箱结果M3 数据过滤分布图):等宽 binCount 个箱,落在 [min,max] 区间内计数。
// binMin/binMax = 分箱区间端点(= 入参 min/maxstep = 单箱宽度counts[i] = 第 i 箱内点数。
// 边界归属:左闭右开 [lo,hi),末箱右闭以纳入恰等于 max 的点。
struct ScatterHistogram {
double binMin = 0.0;
double binMax = 0.0;
double step = 0.0;
std::vector<int> counts;
};
// 对 v 数组在 [min,max] 区间按 binCount 等宽分箱M3对照原版 D3 直方图 stepRange=20
// 非有限值NaN/inf跳过区间外的点不计入min>=max 或 binCount<=0 → counts 全 0。
ScatterHistogram buildScatterHistogram(const std::vector<double>& v, double min, double max,
int binCount);
// 行级显隐状态取反M2对照原版 updateStatus = record.displayStatus ? 0 : 1
// 入参/返回 0=显示、1=隐藏。当前显示(0) → 隐藏(1);当前隐藏(非0) → 显示(0)。
int toggledDisplayStatus(int currentStatus);
} // namespace geopro::app } // namespace geopro::app

View File

@ -13,6 +13,7 @@
#include "FormKit.hpp" #include "FormKit.hpp"
#include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody #include "panels/chart/ScatterDataOps.hpp" // buildScatterFilterBody
#include "panels/chart/ScatterHistogram.hpp"
#include "repo/IDatasetCommandRepository.hpp" #include "repo/IDatasetCommandRepository.hpp"
namespace geopro::app { namespace geopro::app {
@ -22,20 +23,27 @@ constexpr double kSpinRange = 1e12; // 数值范围足够宽,覆盖电阻率/
} // namespace } // namespace
ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo,
QString dsObjectId, QString vFieldCode, QWidget* parent) QString dsObjectId, QString vFieldCode,
std::vector<double> values, QWidget* parent)
: QDialog(parent), : QDialog(parent),
repo_(repo), repo_(repo),
dsObjectId_(std::move(dsObjectId)), dsObjectId_(std::move(dsObjectId)),
vFieldCode_(std::move(vFieldCode)) { vFieldCode_(std::move(vFieldCode)) {
setWindowTitle(QStringLiteral("数据过滤")); setWindowTitle(QStringLiteral("数据过滤"));
setModal(true); setModal(true);
resize(360, 200); resize(640, 300);
auto* root = formkit::dialogRoot(this); auto* root = formkit::dialogRoot(this);
rangeLabel_ = new QLabel(QStringLiteral("数值范围:—"), this); rangeLabel_ = new QLabel(QStringLiteral("数值范围:—"), this);
root->addWidget(rangeLabel_); root->addWidget(rangeLabel_);
// 主体:左直方图 + 右范围输入(横向分栏,对照原版左图右控件布局)。
auto* bodyLay = new QHBoxLayout();
histogram_ = new ScatterHistogramView(this);
histogram_->setValues(values);
bodyLay->addWidget(histogram_, 1);
auto* card = formkit::formCard(this); auto* card = formkit::formCard(this);
auto* cardLay = formkit::cardBody(card); auto* cardLay = formkit::cardBody(card);
@ -51,7 +59,14 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository
form->addRow(formkit::editLabel(QStringLiteral("最小值")), minSpin_); form->addRow(formkit::editLabel(QStringLiteral("最小值")), minSpin_);
form->addRow(formkit::editLabel(QStringLiteral("最大值")), maxSpin_); form->addRow(formkit::editLabel(QStringLiteral("最大值")), maxSpin_);
cardLay->addLayout(form); cardLay->addLayout(form);
root->addWidget(card); bodyLay->addWidget(card);
root->addLayout(bodyLay);
// min/max 改动 → 实时同步直方图选区高亮(原版输入/滑块联动指示矩形)。
connect(minSpin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
[this](double) { syncHistogramSel(); });
connect(maxSpin_, QOverload<double>::of(&QDoubleSpinBox::valueChanged), this,
[this](double) { syncHistogramSel(); });
auto* btnLay = new QHBoxLayout(); auto* btnLay = new QHBoxLayout();
btnLay->addStretch(); btnLay->addStretch();
@ -68,6 +83,10 @@ ScatterFilterDialog::ScatterFilterDialog(geopro::data::IDatasetCommandRepository
loadConfig(); loadConfig();
} }
void ScatterFilterDialog::syncHistogramSel() {
if (histogram_) histogram_->setSelection(minSpin_->value(), maxSpin_->value());
}
void ScatterFilterDialog::loadConfig() { void ScatterFilterDialog::loadConfig() {
if (!repo_) return; if (!repo_) return;
QPointer<ScatterFilterDialog> self(this); QPointer<ScatterFilterDialog> self(this);
@ -81,6 +100,7 @@ void ScatterFilterDialog::loadConfig() {
self->rangeLabel_->setText(QStringLiteral("数值范围:%1 — %2") self->rangeLabel_->setText(QStringLiteral("数值范围:%1 — %2")
.arg(QString::number(mn, 'g', 6), .arg(QString::number(mn, 'g', 6),
QString::number(mx, 'g', 6))); QString::number(mx, 'g', 6)));
self->syncHistogramSel(); // 初值就位后同步直方图选区
}); });
} }

View File

@ -1,4 +1,6 @@
#pragma once #pragma once
#include <vector>
#include <QDialog> #include <QDialog>
#include <QString> #include <QString>
@ -12,20 +14,25 @@ class IDatasetCommandRepository;
namespace geopro::app { namespace geopro::app {
// 「数据过滤」对话框(复刻原版 web dataFilter.vue 的范围过滤部分): class ScatterHistogramView;
// 打开时经 getScatterFilterConfig 取 min/max 初值填入「最小值/最大值」输入框;
// 「数据过滤」对话框(复刻原版 web dataFilter.vue
// 左侧数值分布直方图(自绘 ScatterHistogram由打开方传入当前 V 值数组),
// 右侧 min/max 输入框;改 min/max 时直方图同步高亮选区(对照原版 .filter-indicator
// 打开时经 getScatterFilterConfig 取 min/max 初值填入输入框;
// 「应用过滤」经 applyScatterFilter 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max})。 // 「应用过滤」经 applyScatterFilter 生成过滤后数据集({sourceDsObjectId, sourceVFieldCode, min, max})。
// 直方图(原版左侧 D3 分布图)本轮后置:范围过滤为核心,直方图仅可视化辅助。
// 回调用 QPointer 守卫(虽 modal exec仍异步回调 // 回调用 QPointer 守卫(虽 modal exec仍异步回调
class ScatterFilterDialog : public QDialog { class ScatterFilterDialog : public QDialog {
Q_OBJECT Q_OBJECT
public: public:
// values = 当前 V 值数组(与图上散点 v 同源,驱动直方图分布);可空(无值则直方图空态)。
ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId, ScatterFilterDialog(geopro::data::IDatasetCommandRepository* repo, QString dsObjectId,
QString vFieldCode, QWidget* parent = nullptr); QString vFieldCode, std::vector<double> values, QWidget* parent = nullptr);
private: private:
void loadConfig(); // 取 min/max 初值 void loadConfig(); // 取 min/max 初值
void onApply(); // 应用过滤 void onApply(); // 应用过滤
void syncHistogramSel(); // 把当前 min/max 同步到直方图选区高亮
geopro::data::IDatasetCommandRepository* repo_ = nullptr; geopro::data::IDatasetCommandRepository* repo_ = nullptr;
QString dsObjectId_; QString dsObjectId_;
@ -35,6 +42,7 @@ private:
QDoubleSpinBox* minSpin_ = nullptr; QDoubleSpinBox* minSpin_ = nullptr;
QDoubleSpinBox* maxSpin_ = nullptr; QDoubleSpinBox* maxSpin_ = nullptr;
QPushButton* applyBtn_ = nullptr; QPushButton* applyBtn_ = nullptr;
ScatterHistogramView* histogram_ = nullptr; // 左侧分布直方图
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -0,0 +1,120 @@
#include "panels/chart/ScatterHistogram.hpp"
#include <algorithm>
#include <cmath>
#include <QPaintEvent>
#include <QPainter>
#include "panels/chart/ScatterDataOps.hpp" // buildScatterHistogram
namespace geopro::app {
namespace {
constexpr int kBinCount = 20; // 分箱数(对照原版 D3 stepRange=20
const QColor kBarIn(64, 128, 255); // 选区内柱:蓝(对照原版高亮)
const QColor kBarOut(200, 205, 215); // 选区外柱:灰
const QColor kIndicator(64, 128, 255, 51); // 选区指示矩形rgba(64,128,255,0.2)
const QColor kAxis(150, 150, 150); // 轴线/刻度
constexpr int kPadL = 8; // 左右内边距
constexpr int kPadR = 8;
constexpr int kPadTop = 8; // 顶部内边距
constexpr int kAxisH = 18; // 底部刻度区高度
constexpr int kBarGap = 1; // 柱间距(像素)
} // namespace
ScatterHistogramView::ScatterHistogramView(QWidget* parent) : QWidget(parent) {
setMinimumHeight(160);
setMinimumWidth(280);
}
void ScatterHistogramView::setValues(const std::vector<double>& values) {
values_.clear();
values_.reserve(values.size());
for (double x : values)
if (std::isfinite(x)) values_.push_back(x);
if (values_.empty()) {
dataMin_ = dataMax_ = 0.0;
} else {
dataMin_ = *std::min_element(values_.begin(), values_.end());
dataMax_ = *std::max_element(values_.begin(), values_.end());
}
update();
}
void ScatterHistogramView::setSelection(double min, double max) {
selMin_ = min;
selMax_ = max;
hasSel_ = (min <= max);
update();
}
void ScatterHistogramView::paintEvent(QPaintEvent*) {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing, false);
const QRect r = rect();
const int plotL = r.left() + kPadL;
const int plotR = r.right() - kPadR;
const int plotTop = r.top() + kPadTop;
const int plotBottom = r.bottom() - kAxisH;
const int plotW = plotR - plotL;
const int plotH = plotBottom - plotTop;
if (plotW <= 0 || plotH <= 0) return;
// 数据域无效(无值/退化区间)→ 仅画基线,空态。
if (values_.empty() || !(dataMax_ > dataMin_)) {
p.setPen(kAxis);
p.drawLine(plotL, plotBottom, plotR, plotBottom);
return;
}
// 在全量数据域上分箱(每柱代表一个等宽区间)。
const auto h = buildScatterHistogram(values_, dataMin_, dataMax_, kBinCount);
const int n = static_cast<int>(h.counts.size());
if (n <= 0) return;
const int maxCount = *std::max_element(h.counts.begin(), h.counts.end());
if (maxCount <= 0) {
p.setPen(kAxis);
p.drawLine(plotL, plotBottom, plotR, plotBottom);
return;
}
// 值 → 像素 x 的映射(数据域 [dataMin,dataMax] → [plotL,plotR])。
auto xPix = [&](double val) {
return plotL + (val - dataMin_) / (dataMax_ - dataMin_) * plotW;
};
// 选区指示矩形(先画,柱叠其上)。
if (hasSel_ && selMax_ > selMin_) {
const double lo = std::clamp(selMin_, dataMin_, dataMax_);
const double hi = std::clamp(selMax_, dataMin_, dataMax_);
const QRectF ind(xPix(lo), plotTop, xPix(hi) - xPix(lo), plotH);
p.fillRect(ind, kIndicator);
}
// 画柱:高度按计数归一;选区内蓝、选区外灰。
const double binW = static_cast<double>(plotW) / n;
for (int i = 0; i < n; ++i) {
const double binLo = dataMin_ + i * h.step;
const double binHi = binLo + h.step;
const int barH = static_cast<int>(static_cast<double>(h.counts[static_cast<std::size_t>(i)]) /
maxCount * plotH);
const int bx = static_cast<int>(plotL + i * binW) + kBarGap;
const int bw = std::max(1, static_cast<int>(binW) - 2 * kBarGap);
// 柱中心落在选区内 → 高亮(与原版“区间内点高亮”观感一致)。
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);
}
// 底部基线 + 两端数值刻度min/max
p.setPen(kAxis);
p.drawLine(plotL, plotBottom, plotR, plotBottom);
p.drawText(QRect(plotL, plotBottom, plotW / 2, kAxisH), Qt::AlignLeft | Qt::AlignVCenter,
QString::number(dataMin_, 'g', 4));
p.drawText(QRect(plotL + plotW / 2, plotBottom, plotW / 2, kAxisH),
Qt::AlignRight | Qt::AlignVCenter, QString::number(dataMax_, 'g', 4));
}
} // namespace geopro::app

View File

@ -0,0 +1,36 @@
#pragma once
#include <vector>
#include <QWidget>
namespace geopro::app {
// 数值分布直方图M3 数据过滤对话框左侧分布图,复刻原版 dataFilter.vue 的 D3 直方图)。
// 自绘 QWidget按全量 v 值的数据域分箱画柱;当前选定 [min,max] 区间内的柱高亮蓝、区间外灰,
// 并在选区上叠加半透明蓝色指示矩形(对照原版 .filter-indicator。x 轴底部画数值刻度。
// 选区由对话框输入框/范围联动 setSelection 更新(本控件只读展示,不发选择信号)。
// 命名加 View 后缀以与 ScatterDataOps.hpp 的数据结构 struct ScatterHistogram分箱结果区分
// 避免同名 geopro::app::ScatterHistogram 在同一 TU 内冲突。
class ScatterHistogramView : public QWidget {
Q_OBJECT
public:
explicit ScatterHistogramView(QWidget* parent = nullptr);
// 设置全量 v 值(取有限值数据域作为分箱总区间,分箱数固定 kBinCount
void setValues(const std::vector<double>& values);
// 设置当前选定区间(区间内柱高亮 + 指示矩形。min>max 时不画指示矩形。
void setSelection(double min, double max);
protected:
void paintEvent(QPaintEvent* event) override;
private:
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
};
} // namespace geopro::app

View File

@ -0,0 +1,94 @@
#include "panels/chart/ScatterMarqueePicker.hpp"
#include <algorithm>
#include <QEvent>
#include <QMouseEvent>
#include <QRect>
#include <QRectF>
#include <QRubberBand>
#include <qwt_plot.h>
#include <qwt_plot_canvas.h>
#include <qwt_scale_map.h>
#include "model/Field.hpp"
#include "panels/chart/ChartPickGeometry.hpp"
namespace geopro::app {
ScatterMarqueePicker::ScatterMarqueePicker(QwtPlot* plot, int xAxis, int yAxis, QObject* parent)
: QObject(parent), plot_(plot), xAxis_(xAxis), yAxis_(yAxis) {
// 后装于 LivePanner → 事件链中先收到active 时优先消费左键拖拽,禁用平移)。
if (plot_ && plot_->canvas()) plot_->canvas()->installEventFilter(this);
}
void ScatterMarqueePicker::setActive(bool on) {
active_ = on;
if (!on) {
dragging_ = false;
if (band_) band_->hide();
if (plot_ && plot_->canvas()) plot_->canvas()->unsetCursor();
} else if (plot_ && plot_->canvas()) {
plot_->canvas()->setCursor(Qt::CrossCursor); // 对齐原版 crosshair
}
}
bool ScatterMarqueePicker::eventFilter(QObject* obj, QEvent* ev) {
if (!active_ || !plot_ || obj != plot_->canvas()) return QObject::eventFilter(obj, ev);
switch (ev->type()) {
case QEvent::MouseButtonPress: {
auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() != Qt::LeftButton) break;
dragging_ = true;
origin_ = me->pos();
if (!band_) band_ = new QRubberBand(QRubberBand::Rectangle, plot_->canvas());
band_->setGeometry(QRect(origin_, QSize()));
band_->show();
return true; // 消费 → 不触发 LivePanner 平移
}
case QEvent::MouseMove: {
if (!dragging_) break;
auto* me = static_cast<QMouseEvent*>(ev);
if (band_) band_->setGeometry(QRect(origin_, me->pos()).normalized());
return true;
}
case QEvent::MouseButtonRelease: {
auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() != Qt::LeftButton || !dragging_) break;
dragging_ = false;
if (band_) band_->hide();
finishSelection(me->pos());
return true;
}
case QEvent::MouseButtonDblClick: {
// 框选态吞掉双击,避免穿透触发 LivePanner行为一致
auto* me = static_cast<QMouseEvent*>(ev);
if (me->button() == Qt::LeftButton) return true;
break;
}
default:
break;
}
return QObject::eventFilter(obj, ev);
}
void ScatterMarqueePicker::finishSelection(const QPoint& endPos) {
if (!field_ || !onSelected_ || !plot_) return;
// 近似单击(拖拽距离 < 3px→ 视为误触,不产生空选回调(避免清掉已有选区)。
const QRect pxBox = QRect(origin_, endPos).normalized();
if (pxBox.width() < 3 && pxBox.height() < 3) return;
// 像素橡皮筋 → 数据坐标矩形(两端点各自反变换后取 normalized
const QwtScaleMap xMap = plot_->canvasMap(xAxis_);
const QwtScaleMap yMap = plot_->canvasMap(yAxis_);
const double x0 = xMap.invTransform(origin_.x());
const double x1 = xMap.invTransform(endPos.x());
const double y0 = yMap.invTransform(origin_.y());
const double y1 = yMap.invTransform(endPos.y());
QRectF rect(QPointF(std::min(x0, x1), std::min(y0, y1)),
QPointF(std::max(x0, x1), std::max(y0, y1)));
onSelected_(pointsInRect(*field_, rect));
}
} // namespace geopro::app

View File

@ -0,0 +1,54 @@
#pragma once
#include <functional>
#include <vector>
#include <QObject>
#include <QPoint>
#include <QPointer>
class QwtPlot;
class QRubberBand;
namespace geopro::core {
struct ScatterField;
}
namespace geopro::app {
// M14 框选拾取器:开启后接管画布左键拖拽,画橡皮筋矩形 → 松手把框内散点下标回调出去。
// 复刻原版散点「点选模式」plotly selectmode:'select' 的 box-select 框选):开启时禁用平移
// (事件优先于 LivePanner 消费拖拽),关闭时让位平移/hover。本类不拥有 plot/field外部持有
// 地址稳定);用 QPointer 守 plot父子树管理生命周期。
class ScatterMarqueePicker : public QObject {
Q_OBJECT
public:
// xAxis/yAxis散点所在轴RawData 为 xTop/yLeft。field 由 RawDataChartView 持有(其
// 成员 data_.scatter地址稳定onSelected 回调收框内下标(数据坐标命中)。
ScatterMarqueePicker(QwtPlot* plot, int xAxis, int yAxis, QObject* parent = nullptr);
void setField(const geopro::core::ScatterField* field) { field_ = field; }
void setOnSelected(std::function<void(const std::vector<int>&)> cb) { onSelected_ = std::move(cb); }
// 开/关框选模式。关闭时收起橡皮筋(不清已选——清选区由调用方决定)。
void setActive(bool on);
bool isActive() const { return active_; }
protected:
bool eventFilter(QObject* obj, QEvent* ev) override;
private:
void finishSelection(const QPoint& endPos);
QPointer<QwtPlot> plot_;
int xAxis_;
int yAxis_;
const geopro::core::ScatterField* field_ = nullptr;
std::function<void(const std::vector<int>&)> onSelected_;
QRubberBand* band_ = nullptr; // 父=canvas随之析构
bool active_ = false;
bool dragging_ = false;
QPoint origin_;
};
} // namespace geopro::app

View File

@ -47,6 +47,27 @@ QRectF ScatterPlotItem::boundingRect() const {
return bounding_; return bounding_;
} }
void ScatterPlotItem::setSelectedIndices(const std::vector<int>& indices) {
selected_.clear();
const int n = static_cast<int>(std::min(field_.x.size(), field_.y.size()));
for (int i : indices)
if (i >= 0 && i < n) selected_.insert(i);
}
void ScatterPlotItem::clearSelection() {
selected_.clear();
}
std::vector<QString> ScatterPlotItem::getSelectedIds() const {
std::vector<QString> ids;
for (int i : selected_) {
if (i < 0 || static_cast<std::size_t>(i) >= field_.id.size()) continue;
const QString id = QString::fromStdString(field_.id[i]);
if (!id.isEmpty()) ids.push_back(id);
}
return ids;
}
void ScatterPlotItem::draw(QPainter* painter, void ScatterPlotItem::draw(QPainter* painter,
const QwtScaleMap& xMap, const QwtScaleMap& xMap,
const QwtScaleMap& yMap, const QwtScaleMap& yMap,
@ -82,8 +103,13 @@ void ScatterPlotItem::draw(QPainter* painter,
// 隐藏数据方块:电极已绘,直接收尾(保留电极菱形)。 // 隐藏数据方块:电极已绘,直接收尾(保留电极菱形)。
if (!scatterVisible_) { painter->restore(); return; } if (!scatterVisible_) { painter->restore(); return; }
painter->setPen(QPen(Qt::white, kPenWidth)); const QPen normalPen(Qt::white, kPenWidth);
const QPen selectPen(QColor(255, 0, 0), kSelectPenWidth); // M14 选中红框
const auto& status = field_.displayStatus; // 0=显示 1=隐藏M14 选中隐藏后逐点生效)
const bool hasStatus = status.size() == n;
for (std::size_t i = 0; i < n; ++i) { for (std::size_t i = 0; i < n; ++i) {
// 逐点隐藏M14仅隐藏选中点displayStatus!=0 的点不绘制。
if (hasStatus && status[i] != 0) continue;
double px = xMap.transform(xs[i]); double px = xMap.transform(xs[i]);
double py = yMap.transform(ys[i]); double py = yMap.transform(ys[i]);
double val = (i < vs.size()) ? vs[i] : 0.0; double val = (i < vs.size()) ? vs[i] : 0.0;
@ -91,6 +117,7 @@ void ScatterPlotItem::draw(QPainter* painter,
// 与 RawDataChartView 的 setDataRange 的 isfinite 跳过一致。 // 与 RawDataChartView 的 setDataRange 的 isfinite 跳过一致。
if (!std::isfinite(val)) continue; if (!std::isfinite(val)) continue;
auto c = colorSvc_->colorAtContinuous(val); auto c = colorSvc_->colorAtContinuous(val);
painter->setPen(selected_.count(static_cast<int>(i)) ? selectPen : normalPen);
painter->setBrush(QColor(c.r, c.g, c.b, c.a)); painter->setBrush(QColor(c.r, c.g, c.b, c.a));
painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide, painter->drawRect(QRectF(px - kHalfSide, py - kHalfSide,
kHalfSide * 2.0, kHalfSide * 2.0)); kHalfSide * 2.0, kHalfSide * 2.0));

View File

@ -1,8 +1,12 @@
#pragma once #pragma once
#include <set>
#include <vector>
#include "model/Field.hpp" #include "model/Field.hpp"
#include "panels/chart/ColorMapService.hpp" #include "panels/chart/ColorMapService.hpp"
#include <qwt_plot_item.h> #include <qwt_plot_item.h>
#include <QRectF> #include <QRectF>
#include <QString>
class QPainter; class QPainter;
class QwtScaleMap; class QwtScaleMap;
@ -22,6 +26,13 @@ public:
// 显示/隐藏数据方块measurement 工具条“显示/隐藏”)。电极菱形不受影响,始终绘制。 // 显示/隐藏数据方块measurement 工具条“显示/隐藏”)。电极菱形不受影响,始终绘制。
void setScatterVisible(bool on) { scatterVisible_ = on; } void setScatterVisible(bool on) { scatterVisible_ = on; }
// M14 框选:设/清选中点下标集(红色加粗描边高亮,对齐原版 marker.line red/2
void setSelectedIndices(const std::vector<int>& indices);
void clearSelection();
// M14当前选中点的 id对照原版 getSelectedPointIds供 saveDisplayStatus。空 id 跳过。
std::vector<QString> getSelectedIds() const;
bool hasSelection() const { return !selected_.empty(); }
int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; } int rtti() const override { return QwtPlotItem::Rtti_PlotUserItem; }
QRectF boundingRect() const override; QRectF boundingRect() const override;
@ -36,6 +47,7 @@ private:
ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有 ColorMapService* colorSvc_ = nullptr; // 不拥有,由 RawDataChartView 持有
QRectF bounding_; QRectF bounding_;
bool scatterVisible_ = true; // false仅画电极隐藏数据方块 bool scatterVisible_ = true; // false仅画电极隐藏数据方块
std::set<int> selected_; // M14 选中点下标(红框高亮)
// 数据方块:原版 Plotly marker.size 12px绝对像素直径→ 半边长 6px真 1:1。 // 数据方块:原版 Plotly marker.size 12px绝对像素直径→ 半边长 6px真 1:1。
static constexpr double kHalfSide = 6.0; // 方块半边长(像素,全宽 12px static constexpr double kHalfSide = 6.0; // 方块半边长(像素,全宽 12px
@ -44,6 +56,8 @@ private:
// 相对数据方块 ≈1.33×16 vs 12实心 #BEBEBE 填充 + 白色 2px 描边。 // 相对数据方块 ≈1.33×16 vs 12实心 #BEBEBE 填充 + 白色 2px 描边。
static constexpr double kElectrodeHalfSide = 8.0; // 半对角(像素,全宽 16px static constexpr double kElectrodeHalfSide = 8.0; // 半对角(像素,全宽 16px
static constexpr double kElectrodePenWidth = 2.0; // 电极白色描边宽度(像素) static constexpr double kElectrodePenWidth = 2.0; // 电极白色描边宽度(像素)
// M14 选中描边:红 2px对齐原版 marker.line color:'red' width:2
static constexpr double kSelectPenWidth = 2.0;
}; };
} // namespace geopro::app } // namespace geopro::app

View File

@ -80,6 +80,11 @@ struct TablePayload {
int pageNo = 1; // 当前页(1 基);分页用 int pageNo = 1; // 当前页(1 基);分页用
int pageSize = 0; // 每页条数;>0 才渲染分页器(vxe-pager)0=不分页 int pageSize = 0; // 每页条数;>0 才渲染分页器(vxe-pager)0=不分页
std::vector<TableFunctionButton> functionButtons; // dd_grid 功能按钮(其余场景空) std::vector<TableFunctionButton> functionButtons; // dd_grid 功能按钮(其余场景空)
// M2 行级显隐:仅 measurement 列表置 true载荷驱动门控其余视图复用 DataTableView 保持只读)。
// 为 true 时 Toggle 列可点击 → popconfirm → saveDisplayStatus(rowIds[i], 取反)。
bool toggleInteractive = false;
// 每行点 id与 rows 同序、一一对应saveDisplayStatus ids[] 用);仅 measurement 填充。
std::vector<QString> rowIds;
}; };
// 柱状图系列:名称(图例/legend+ 各类目的 y 值 + 填充色hex如 #5470c6数据色两主题一致 // 柱状图系列:名称(图例/legend+ 各类目的 y 值 + 填充色hex如 #5470c6数据色两主题一致

View File

@ -40,6 +40,14 @@ QString strAt(const QJsonArray& arr, int i) {
return QString(); return QString();
} }
// 取对象里的字符串字段id 可能为字符串或数字形态,统一转字符串)。
QString objStr(const QJsonObject& obj, const QString& key) {
const QJsonValue v = obj.value(key);
if (v.isString()) return v.toString();
if (v.isDouble()) return QString::number(v.toDouble(), 'f', 0);
return QString();
}
// 把 JSON 值预格式化为单元格 QString数字按原值null/缺省→空串)。 // 把 JSON 值预格式化为单元格 QString数字按原值null/缺省→空串)。
QString cellText(const QJsonValue& v) { QString cellText(const QJsonValue& v) {
if (v.isDouble()) { if (v.isDouble()) {
@ -149,6 +157,8 @@ TablePayload parseMeasurementTable(const QJsonObject& data) {
toggle.title = QStringLiteral("隐藏/显示"); toggle.title = QStringLiteral("隐藏/显示");
toggle.kind = TableColumnKind::Toggle; toggle.kind = TableColumnKind::Toggle;
t.columns.push_back(toggle); t.columns.push_back(toggle);
// M2仅 measurement 列表的 Toggle 列可交互(载荷驱动门控,其余复用视图保持只读)。
t.toggleInteractive = true;
} }
// 行:每格按列码先查 vmap再查行对象顶层。 // 行:每格按列码先查 vmap再查行对象顶层。
@ -169,6 +179,8 @@ TablePayload parseMeasurementTable(const QJsonObject& data) {
cells.push_back(cellText(v)); cells.push_back(cellText(v));
} }
t.rows.push_back(std::move(cells)); t.rows.push_back(std::move(cells));
// M2每行点 idsaveDisplayStatus ids[] 用);仅 measurement(hasToggleCol) 填充。
if (hasToggleCol) t.rowIds.push_back(objStr(obj, QStringLiteral("id")));
} }
// 总数分页用measurement 用 __rowListTotal回退到本批行数。 // 总数分页用measurement 用 __rowListTotal回退到本批行数。

View File

@ -150,6 +150,11 @@ target_sources(geopro_tests PRIVATE
app/test_scatter_data_ops.cpp app/test_scatter_data_ops.cpp
${CMAKE_SOURCE_DIR}/src/app/panels/chart/ScatterDataOps.cpp ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ScatterDataOps.cpp
) )
# M14 pointsInRect / I9 QtCore QPointF/QRectF + core model
target_sources(geopro_tests PRIVATE
app/test_chart_pick_geometry.cpp
${CMAKE_SOURCE_DIR}/src/app/panels/chart/ChartPickGeometry.cpp
)
# // + code Qt6::Core JSON # // + code Qt6::Core JSON
target_sources(geopro_tests PRIVATE target_sources(geopro_tests PRIVATE
app/test_inversion_process_ops.cpp app/test_inversion_process_ops.cpp
@ -160,6 +165,13 @@ target_sources(geopro_tests PRIVATE
app/test_contour_simplify.cpp app/test_contour_simplify.cpp
${CMAKE_SOURCE_DIR}/src/app/panels/chart/ContourSimplify.cpp ${CMAKE_SOURCE_DIR}/src/app/panels/chart/ContourSimplify.cpp
) )
# Quill Delta QTextDocument I14 Qt6::Core/Gui
find_package(Qt6 COMPONENTS Gui REQUIRED)
target_sources(geopro_tests PRIVATE
app/test_quill_delta.cpp
${CMAKE_SOURCE_DIR}/src/app/panels/QuillDelta.cpp
)
target_link_libraries(geopro_tests PRIVATE Qt6::Gui)
# controller DatasetDetailController QSignalSpy datasetOpened/tabReady/loadFailed # controller DatasetDetailController QSignalSpy datasetOpened/tabReady/loadFailed
find_package(Qt6 COMPONENTS Test REQUIRED) find_package(Qt6 COMPONENTS Test REQUIRED)

View File

@ -0,0 +1,79 @@
#include <gtest/gtest.h>
#include <QPointF>
#include <QRectF>
#include "panels/chart/ChartPickGeometry.hpp"
using namespace geopro::app;
using geopro::core::ScatterField;
namespace {
ScatterField makeField() {
ScatterField f;
f.x = {0.0, 1.0, 2.0, 3.0};
f.y = {0.0, 1.0, 2.0, 3.0};
f.id = {"a", "b", "c", "d"};
f.displayStatus = {0, 0, 0, 0};
return f;
}
} // namespace
TEST(ChartPickGeometry, PointsInRectSelectsInterior) {
auto f = makeField();
// 矩形 [0.5,2.5]×[0.5,2.5] → 命中下标 1(1,1) 与 2(2,2)。
auto hits = pointsInRect(f, QRectF(QPointF(0.5, 0.5), QPointF(2.5, 2.5)));
ASSERT_EQ(hits.size(), 2u);
EXPECT_EQ(hits[0], 1);
EXPECT_EQ(hits[1], 2);
}
TEST(ChartPickGeometry, PointsInRectEmptyWhenOutside) {
auto f = makeField();
auto hits = pointsInRect(f, QRectF(QPointF(10.0, 10.0), QPointF(20.0, 20.0)));
EXPECT_TRUE(hits.empty());
}
TEST(ChartPickGeometry, PointsInRectSkipsHidden) {
auto f = makeField();
f.displayStatus = {0, 1, 0, 0}; // 下标 1 隐藏 → 不参与框选
auto hits = pointsInRect(f, QRectF(QPointF(0.5, 0.5), QPointF(2.5, 2.5)));
ASSERT_EQ(hits.size(), 1u);
EXPECT_EQ(hits[0], 2);
}
TEST(ChartPickGeometry, PointsInRectSkipsNonFinite) {
ScatterField f;
f.x = {0.0, std::nan("")};
f.y = {0.0, 0.0};
auto hits = pointsInRect(f, QRectF(QPointF(-1.0, -1.0), QPointF(1.0, 1.0)));
ASSERT_EQ(hits.size(), 1u);
EXPECT_EQ(hits[0], 0);
}
TEST(ChartPickGeometry, MinPointsForMarkType) {
EXPECT_EQ(minPointsForMarkType(1), 1); // 点
EXPECT_EQ(minPointsForMarkType(2), 2); // 线
EXPECT_EQ(minPointsForMarkType(3), 3); // 面
EXPECT_EQ(minPointsForMarkType(4), 1); // 文字
}
TEST(ChartPickGeometry, NormalizePointTakesFirstOnly) {
std::vector<QPointF> pts{{1.0, 2.0}, {3.0, 4.0}};
auto out = normalizeDrawnPoints(pts, /*point*/ 1);
ASSERT_EQ(out.size(), 1u);
EXPECT_DOUBLE_EQ(out[0].x(), 1.0);
// 文字同样取首点。
EXPECT_EQ(normalizeDrawnPoints(pts, /*text*/ 4).size(), 1u);
}
TEST(ChartPickGeometry, NormalizeLineKeepsAll) {
std::vector<QPointF> pts{{1.0, 2.0}, {3.0, 4.0}, {5.0, 6.0}};
EXPECT_EQ(normalizeDrawnPoints(pts, /*line*/ 2).size(), 3u);
EXPECT_EQ(normalizeDrawnPoints(pts, /*polygon*/ 3).size(), 3u);
}
TEST(ChartPickGeometry, CanClosePolygon) {
EXPECT_FALSE(canClosePolygon(2));
EXPECT_TRUE(canClosePolygon(3));
}

View File

@ -0,0 +1,177 @@
#include <gtest/gtest.h>
#include <QJsonArray>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTextBlock>
#include <QTextDocument>
#include <QTextFragment>
#include "panels/QuillDelta.hpp"
using namespace geopro::app;
namespace {
QJsonArray opsFromJson(const char* json) {
return QJsonDocument::fromJson(json).array();
}
// 从 Delta ops 取第 idx 个文本 op 的 attributes便于断言
QJsonObject attrsOf(const QJsonArray& ops, int idx) {
return ops.at(idx).toObject().value(QStringLiteral("attributes")).toObject();
}
} // namespace
// ── 反序列化Delta → 文档):基本行内样式落到字符格式 ──────────────────────
TEST(QuillDelta, DeltaToDocumentAppliesInlineFormats) {
const auto ops = opsFromJson(R"([
{"insert":"Hello","attributes":{"bold":true,"italic":true,"underline":true,
"color":"#ff0000","size":"24px"}},
{"insert":"\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.toPlainText(), QStringLiteral("Hello"));
const QTextFragment frag = doc.begin().begin().fragment();
ASSERT_TRUE(frag.isValid());
const QTextCharFormat f = frag.charFormat();
EXPECT_GE(f.fontWeight(), QFont::Bold);
EXPECT_TRUE(f.fontItalic());
EXPECT_TRUE(f.fontUnderline());
EXPECT_EQ(f.foreground().color().name(QColor::HexRgb), QStringLiteral("#ff0000"));
EXPECT_NEAR(f.fontPointSize(), 24.0 * 3.0 / 4.0, 0.01); // 24px → 18pt
}
// ── 序列化(文档 → Delta行内样式回写 attributes ─────────────────────────
TEST(QuillDelta, DocumentToDeltaEmitsInlineAttrs) {
QTextDocument doc;
QTextCursor cur(&doc);
QTextCharFormat f;
f.setFontWeight(QFont::Bold);
f.setForeground(QColor(QStringLiteral("#00ff00")));
cur.insertText(QStringLiteral("World"), f);
const QJsonArray ops = documentToDelta(doc);
ASSERT_GE(ops.size(), 1);
EXPECT_EQ(ops.at(0).toObject().value(QStringLiteral("insert")).toString(),
QStringLiteral("World"));
const QJsonObject a = attrsOf(ops, 0);
EXPECT_TRUE(a.value(QStringLiteral("bold")).toBool());
EXPECT_EQ(a.value(QStringLiteral("color")).toString(), QStringLiteral("#00ff00"));
}
// ── 往返:纯文本不丢、不多生成空块 ─────────────────────────────────────────
TEST(QuillDelta, RoundTripPlainTextSingleLine) {
const auto ops = opsFromJson(R"([{"insert":"just text"},{"insert":"\n"}])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.toPlainText(), QStringLiteral("just text"));
EXPECT_EQ(doc.blockCount(), 1); // 不应多出尾部空块
const QJsonArray back = documentToDelta(doc);
QString text;
for (const QJsonValue& v : back)
text += v.toObject().value(QStringLiteral("insert")).toString();
EXPECT_EQ(text, QStringLiteral("just text\n"));
}
// ── 往返:多行 + 行内样式保持 ──────────────────────────────────────────────
TEST(QuillDelta, RoundTripMultilineWithBoldPreserved) {
const auto ops = opsFromJson(R"([
{"insert":"line1 "},
{"insert":"bold","attributes":{"bold":true}},
{"insert":"\nline2\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.toPlainText(), QStringLiteral("line1 bold\nline2"));
EXPECT_EQ(doc.blockCount(), 2);
// 第二趟往返稳定:重新序列化后再反序列化文本一致。
const QJsonArray back = documentToDelta(doc);
QTextDocument doc2;
deltaToDocument(back, doc2);
EXPECT_EQ(doc2.toPlainText(), QStringLiteral("line1 bold\nline2"));
EXPECT_EQ(doc2.blockCount(), 2);
}
// ── 块级:标题落到 headingLevel 并回写 header ──────────────────────────────
TEST(QuillDelta, HeaderBlockRoundTrip) {
const auto ops = opsFromJson(R"([
{"insert":"Title"},
{"insert":"\n","attributes":{"header":2}},
{"insert":"body"},
{"insert":"\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.begin().blockFormat().headingLevel(), 2);
const QJsonArray back = documentToDelta(doc);
// 找到带 header 的换行 op。
bool found = false;
for (const QJsonValue& v : back) {
const QJsonObject op = v.toObject();
if (op.value(QStringLiteral("insert")).toString() == QStringLiteral("\n") &&
op.value(QStringLiteral("attributes")).toObject().value(QStringLiteral("header")).toInt() == 2) {
found = true;
break;
}
}
EXPECT_TRUE(found);
}
// ── 块级:有序列表 → ListDecimal回写 list:ordered ───────────────────────
TEST(QuillDelta, OrderedListBlockRoundTrip) {
const auto ops = opsFromJson(R"([
{"insert":"item"},
{"insert":"\n","attributes":{"list":"ordered"}}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
ASSERT_NE(doc.begin().textList(), nullptr);
const QJsonArray back = documentToDelta(doc);
bool found = false;
for (const QJsonValue& v : back) {
const QJsonObject a = v.toObject().value(QStringLiteral("attributes")).toObject();
if (a.value(QStringLiteral("list")).toString() == QStringLiteral("ordered")) {
found = true;
break;
}
}
EXPECT_TRUE(found);
}
// ── 容错:无法识别的 attributes 降级(保留文本,不崩) ──────────────────────
TEST(QuillDelta, UnknownAttributesDegradeGracefully) {
const auto ops = opsFromJson(R"([
{"insert":"keepme","attributes":{"script":"super","strike":true,"link":"http://x"}},
{"insert":"\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.toPlainText(), QStringLiteral("keepme"));
}
// ── 容错:非文本 insert图片/嵌入对象)被丢弃,不崩 ───────────────────────
TEST(QuillDelta, NonStringInsertDropped) {
const auto ops = opsFromJson(R"([
{"insert":"text"},
{"insert":{"image":"data:..."}},
{"insert":"\n"}
])");
QTextDocument doc;
deltaToDocument(ops, doc);
EXPECT_EQ(doc.toPlainText(), QStringLiteral("text"));
}
// ── 空 ops空文档 ─────────────────────────────────────────────────────────
TEST(QuillDelta, EmptyOpsYieldEmptyDocument) {
QTextDocument doc;
deltaToDocument(QJsonArray{}, doc);
EXPECT_TRUE(doc.toPlainText().isEmpty());
}

View File

@ -85,3 +85,40 @@ TEST(ScatterDataOps, SaveRawDataBodyOverwriteOmitsName) {
EXPECT_EQ(body.value("operationType").toInt(), 0); EXPECT_EQ(body.value("operationType").toInt(), 0);
EXPECT_FALSE(body.contains("name")); // 覆盖不带 name EXPECT_FALSE(body.contains("name")); // 覆盖不带 name
} }
TEST(ScatterDataOps, ToggledDisplayStatus) {
// 0=显示 → 1=隐藏1=隐藏 → 0=显示(对照原版 record.displayStatus ? 0 : 1
EXPECT_EQ(toggledDisplayStatus(0), 1);
EXPECT_EQ(toggledDisplayStatus(1), 0);
EXPECT_EQ(toggledDisplayStatus(2), 0); // 非 0 视为隐藏 → 显示
}
TEST(ScatterDataOps, HistogramBinsCountInRange) {
// 0..10 均匀 11 个点min=0 max=10 分 5 箱(宽 2
// [0,2):0,1 → 2[2,4):2,3 → 2[4,6):4,5 → 2[6,8):6,7 → 2[8,10]:8,9,10 → 3末箱右闭
std::vector<double> v{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto h = buildScatterHistogram(v, 0.0, 10.0, 5);
ASSERT_EQ(h.counts.size(), 5u);
EXPECT_DOUBLE_EQ(h.step, 2.0);
EXPECT_EQ(h.counts[0], 2);
EXPECT_EQ(h.counts[1], 2);
EXPECT_EQ(h.counts[2], 2);
EXPECT_EQ(h.counts[3], 2);
EXPECT_EQ(h.counts[4], 3); // 末箱含恰等于 max 的点
}
TEST(ScatterDataOps, HistogramSkipsOutOfRangeAndNonFinite) {
std::vector<double> v{-5, 0, 5, 10, 15, std::nan("")};
auto h = buildScatterHistogram(v, 0.0, 10.0, 2);
ASSERT_EQ(h.counts.size(), 2u);
// 区间外(-5,15)与 NaN 跳过;保留 0,5,10。
// [0,5):0 → 1[5,10]:5,10 → 2。
EXPECT_EQ(h.counts[0], 1);
EXPECT_EQ(h.counts[1], 2);
}
TEST(ScatterDataOps, HistogramDegenerateReturnsEmpty) {
std::vector<double> v{1, 2, 3};
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
}

View File

@ -272,3 +272,30 @@ TEST(MeasurementDto, ToggleOffWhenDisplayStatusNonZero) {
EXPECT_EQ(t.rows[0].back().toStdString(), "1"); // displayStatus 0 → ON EXPECT_EQ(t.rows[0].back().toStdString(), "1"); // displayStatus 0 → ON
EXPECT_EQ(t.rows[1].back().toStdString(), "0"); // displayStatus 1 → OFF EXPECT_EQ(t.rows[1].back().toStdString(), "0"); // displayStatus 1 → OFF
} }
TEST(MeasurementDto, MeasurementListIsToggleInteractiveWithRowIds) {
// M2measurement 列表filedList 驱动)→ toggleInteractive=true + 每行点 id。
auto t = parseMeasurementTable(obj(R"({
"filedList": [{"fieldCode":"a","name":"A"}],
"rowList": [
{"id":"x","a":1,"displayStatus":0},
{"id":1453611521843201,"a":2,"displayStatus":1}
]
})"));
EXPECT_TRUE(t.toggleInteractive);
ASSERT_EQ(t.rowIds.size(), 2u);
EXPECT_EQ(t.rowIds[0].toStdString(), "x");
EXPECT_EQ(t.rowIds[1].toStdString(), "1453611521843201"); // 数字 id 转字符串
}
TEST(MeasurementDto, GridListNotToggleInteractive) {
// grid/trajectory 列表gridHeaderDisplay 驱动)→ 无 Toggle 列、不可交互、无 rowIds。
auto t = parseMeasurementTable(obj(R"({
"gridHeaderDisplay": [{"fieldCode":"a","name":"A"}],
"rowList": [ {"a":1}, {"a":2} ]
})"));
EXPECT_FALSE(t.toggleInteractive);
EXPECT_TRUE(t.rowIds.empty());
for (const auto& c : t.columns)
EXPECT_NE(c.kind, geopro::core::TableColumnKind::Toggle);
}